使用Apereo Cas 5.1.3的Restful接口实现SSO及TGC分析

关于CAS的认证原理,网上已有很多相关文章了,官方文档也有说明,这里就不再详述了。本文重在阐述使用Apereo Cas 5.1.3遇到的与TGC相关的问题,及问题的分析解决过程。

遇到的TGC相关的问题:

  1. 由于需要使用云环境,把cas部署在了华为云上,配置了负载均衡(未启用负载均衡的会话保持),结果登陆状态无法保持,经过查看cas的日志,发现第二次访问的时候,请求的源IP与第一次访问的源IP不一致,被CAS拒绝了。华为云上的负载均衡器是一个集群,对内有多个内网IP,导致出现该问题,启用ELB的会话保持,暂时避开了此问题。
  2. 为了实现CAS集群,实现CAS无状态化,需要把CAS的session、TGT存在外部缓存redis中。实现之后,发现浏览器端的Cookie有一个TGC的值,会话状态是靠TGC保持的(即使删除SessionID,只要TGC存在就能保持登录状态)。在redis中却没发现TGC的踪迹。TGC还存储在CAS程序中?如果是,那么会话状态还是在应用实例中保持的,没有实现将所有状态信息放到程序外部。
  3. 一些业务系统需要有自己的登陆页面,在自己的登陆页面中调用CAS的Restful接口进行登录,同时又希望登录后,再访问其他使用CAS单点登录的系统时,不用再次登录。使用CAS的Restful接口进行登录认证倒容易实现,但再访问其他使用CAS单点登录的系统时,不用再次登录却没法按照CAS官方资料实现。经过分析,主要是由于业务系统使用自己的登陆页面登陆后,没有在CAS的域下写入TGC的Cookie值。要解决这个问题就需要弄清楚TGC的生成及使用逻辑,不得不去了解TGC的来龙去脉了。

解决问题的过程:

通过堆栈信息,顺利找到了CAS页面登录,表单提交的Action : SendTicketGrantingTicketAction,顺藤摸瓜,在doExecute方法中找到了调用CookieRetrievingCookieGeneratoraddCookie相关方法:

this.ticketGrantingTicketCookieGenerator.addCookie(request, response, ticketGrantingTicketId);

在addCookie方法中找到了构造cookie的相关方法:

final String theCookieValue = this.casCookieValueManager.buildCookieValue(cookieValue, request);

CookieValueManager有两个实现类:DefaultCasCookieValueManagerNoOpCookieValueManager。在Debug过程中,发现使用的是DefaultCasCookieValueManager实现类,查看它的buildCookieValue方法:

    @Override
    public String buildCookieValue(final String givenCookieValue, final HttpServletRequest request) {
        final ClientInfo clientInfo = ClientInfoHolder.getClientInfo();
        final StringBuilder builder = new StringBuilder(givenCookieValue)
                .append(COOKIE_FIELD_SEPARATOR)
                .append(clientInfo.getClientIpAddress());

        final String userAgent = WebUtils.getHttpServletRequestUserAgent(request);
        if (StringUtils.isBlank(userAgent)) {
            throw new IllegalStateException("Request does not specify a user-agent");
        }
        builder.append(COOKIE_FIELD_SEPARATOR).append(userAgent);

        final String res = builder.toString();
        LOGGER.debug("Encoding cookie value [{}]", res);
        return this.cipherExecutor.encode(res);
    }

逻辑很简单,方法传入的givenCookieValue的值其实就是TGT的值,把TGT的值使用分隔符附加上客户端IP地址、客户端代理信息(浏览器信息),编码后就得到了TGC的值。
例如:
加密前的值:

TGT-1-VOscp5EQosRdeRJK1vIIHwFCkFOeoHIqTnggPCqJ67TdqbWLi3-yh-PC@0:0:0:0:0:0:0:1@Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:55.0) Gecko/20100101 Firefox/55.0

加密后的值,即客户端浏览器中存储的TGC Cookie的值:

eyJhbGciOiJIUzUxMiJ9.WlhsS05tRllRV2xQYVVwRlVsVlphVXhEU21oaVIyTnBUMmxLYTJGWVNXbE1RMHBzWW0xTmFVOXBTa0pOVkVrMFVUQktSRXhWYUZSTmFsVXlTVzR3TGk1NVJubEpjblpHWVZOR1Z5MVhURjg1UkVweWMzaFJMblI2TUZSRVpVSktjVWwxU1RkWVdGbFdkVk5uZEhkM2NHTlhXbWQwZFc1NlVVSnBUbEJGVkRWaVpYUktPREJGUTBGTExVbDZTR2Q2WlVwMFF6WTRPVTgzWmt4RVQyTjVaVkZZZGpRdFowSnllR1kzYW14MGJUYzRNMVJ5WmpGcmFITmxXbGt3WkVaM0xURkVTMjR6WHpVdFlqWnFUVTFCTVVsUk9FZ3RValkxTlZWclQzZEtNWFI2V1U5U1pFcGtaVk4yZEZSaVkyRmhaMGRFVlVodFUyOXVaSEJIT0RWdlptTkZhek5sYlVsSmVrZHBaMkp4VkZZeE4yY3pWRlZxY2k1Rk0wODNiMHAxY0ZOV1dVRm1SMjFOZDBSRFpEWjM.pZlVEOYioDF9_DJjlmOSTvWJ--WJOJe3dFQpAJRHXwH35XYXPNgcsfQ6NQ-1xoBb_-whvsOdCx68yQNdSWTyYQ

那么TGC的值在服务端是存储在哪里的呢?答案是没有存储。因为浏览器再次发送请求时,会传输来Cookie的值(即从客户端请求中获取TGC),解密TGC的值就获取了对应的TGT信息。看一下该类中对应的获取cookie的方法obtainCookieValue

    @Override
    public String obtainCookieValue(final Cookie cookie, final HttpServletRequest request) {
        final String cookieValue = this.cipherExecutor.decode(cookie.getValue());
        LOGGER.debug("Decoded cookie value is [{}]", cookieValue);
        if (StringUtils.isBlank(cookieValue)) {
            LOGGER.debug("Retrieved decoded cookie value is blank. Failed to decode cookie [{}]", cookie.getName());
            return null;
        }

        final String[] cookieParts = cookieValue.split(String.valueOf(COOKIE_FIELD_SEPARATOR));
        if (cookieParts.length != COOKIE_FIELDS_LENGTH) {
            throw new IllegalStateException("Invalid cookie. Required fields are missing");
        }
        final String value = cookieParts[0];
        final String remoteAddr = cookieParts[1];
        final String userAgent = cookieParts[2];

        if (StringUtils.isBlank(value) || StringUtils.isBlank(remoteAddr) || StringUtils.isBlank(userAgent)) {
            throw new IllegalStateException("Invalid cookie. Required fields are empty");
        }

        final ClientInfo clientInfo = ClientInfoHolder.getClientInfo();
        if (!remoteAddr.equals(clientInfo.getClientIpAddress())) {
            throw new IllegalStateException("Invalid cookie. Required remote address "
                    + remoteAddr + " does not match " + clientInfo.getClientIpAddress());
        }

        final String agent = WebUtils.getHttpServletRequestUserAgent(request);
        if (!userAgent.equals(agent)) {
            throw new IllegalStateException("Invalid cookie. Required user-agent " + userAgent + " does not match " + agent);
        }
        return value;
    }

可以发现,解密TGC后,使用分隔符把字符串分隔后,获取到了TGT、客户端IP信息、客户端代理信息。并将从TGC中解密的客户端IP信息和客户端代理信息与当前请求的客户端IP信息和客户端代理信息进行比较,若不等就抛出异常,这就是问题1产生的原因。
CAS为什么要这么做呢?在普通场景下这增强了安全性,即使TGC被嗅探到,攻击者模拟发送请求到CAS,如果请求来源的IP不一致,或者客户端代理信息不一致,那么就会被CAS检测到并拒绝认证,这在一定程度上增加了安全性。攻击者既然能嗅探到TGC,当然也能获取到正常请求的客户端IP和客户端代理浏览器的信息,当然就能模拟一个能通过CAS验证的请求,前提是攻击者了解CAS的TGC验证过程或者能够想到这一点。
了解了问题1产生的原因那么怎么来修复呢?直接改这个类的代码的方法虽然能解决问题,但显然不够优雅。前面提到了CookieValueManager的另一个实现类:NoOpCookieValueManager,查看这个类的buildCookieValue方法,发现是没做任何处理,直接返回TGT信息。如果使用这个实现类不就行了。搜索查找哪些类使用了DefaultCasCookieValueManager这个类,找到配置类CasCookieConfiguration,类中有如下配置方法:

    @ConditionalOnMissingBean(name = "cookieValueManager")
    @Autowired
    @Bean
    public CookieValueManager cookieValueManager(@Qualifier("cookieCipherExecutor") final CipherExecutor cipherExecutor) {
        if (casProperties.getTgc().isCipherEnabled()) {
            return new CasCookieValueManager(cipherExecutor);
        }
        return new NoOpCookieValueManager();
    }

通过此方法发现,原来是通过cas.tgc.cipherEnabled来配置使用哪个实现类的,cipherEnabled默认是true,那么我们修改该配置为false就能实现使用NoOpCookieValueManager实现类了(即使没有满足需求的实现类,我们也能自己实现一个类,然后修改配置文件使用我们自己的实现类)。
通过以上工作,问题1,2算是解决了,问题3如何处理呢?业务系统调用CAS的Restful接口能获取到TGT信息,即TGC信息(前提是配置cas.tgc.cipherEnabledfalse)。那么怎么把TGC信息写入到客户端浏览器的Cookie中呢?考虑到业务系统可能和CAS不在一个域下,那么就在业务系统获取到TGT后,重定向到CAS的登陆页面(需要带上service参数,以便在CAS页面验证通过后重定向回业务系统页面),TGC作为一个参数传递到CAS的登陆页面,当浏览器使用登陆页面的地址获取登陆页面时,向后端发送请求,由CAS的单点登陆原理可知,浏览器向后端请求登陆页面时,肯定会查询cookie信息,那么我们在CookieRetrievingCookieGeneratorretrieveCookieValue的方法中打印堆栈,查看相关的逻辑。可以在retrieveCookieValue方法中修改获取cookie的逻辑,增加从请求参数中获取tgc的值作为cookie;但在retrieveCookieValue方法中没传入response,无法把请求参数中的tgc写入到客户端浏览器的cookie中。再根据堆栈信息找到InitialFlowSetupAction类的configureWebflowContext方法,在这个方法中修改就能达到目的了。
需要修改的代码:

        WebUtils.putTicketGrantingTicketInScopes(context, this.ticketGrantingTicketCookieGenerator.retrieveCookieValue(request));

修改后的代码:

    String cookie=this.ticketGrantingTicketCookieGenerator.retrieveCookieValue(request);
        if (cookie==null && "/cas/login".equals(request.getRequestURI())) {
            String tgc=request.getParameter("tgc");
            if (tgc!=null && !"".equals(tgc.trim())) {
                cookie=tgc;
                HttpServletResponse response = WebUtils.getHttpServletResponse(context);
                Cookie tgcCookie=new Cookie(this.ticketGrantingTicketCookieGenerator.getCookieName(), cookie);
                if (this.ticketGrantingTicketCookieGenerator.getCookieDomain() != null) {
                    tgcCookie.setDomain(this.ticketGrantingTicketCookieGenerator.getCookieDomain());
                }
                if (this.ticketGrantingTicketCookieGenerator.getCookiePath() != null) {
                    tgcCookie.setPath(this.ticketGrantingTicketCookieGenerator.getCookiePath());
                }
                if (this.ticketGrantingTicketCookieGenerator.getCookieMaxAge() != null) {
                    tgcCookie.setMaxAge(this.ticketGrantingTicketCookieGenerator.getCookieMaxAge());
                }
                if (this.ticketGrantingTicketCookieGenerator.isCookieSecure()) {
                    tgcCookie.setSecure(true);
                }
                if (this.ticketGrantingTicketCookieGenerator.isCookieHttpOnly()) {
                    tgcCookie.setHttpOnly(true);
                }
                response.addCookie(tgcCookie);
            }
        }
        WebUtils.putTicketGrantingTicketInScopes(context,cookie);

这样修改后,业务系统在调用CAS的Restful接口获取到TGT后,重定向到CAS的登陆页面,同时带上service参数(值为当前业务系统的主页)及tgc参数(值为获取到的TGT的值)。这样就实现了调用Restful实现单点登录的效果。
为了让代码更优雅,我们可以根据InitialFlowSetupAction类创建一个自定义的类然后再按照上述内容修改,然后修改配置类CasSupportActionsConfigurationinitialFlowSetupAction方法,改为使用自定义的类来创建对象就行了。

客户端应用程序调用CAS的Restful接口的实现:

客户端应用程序需要先配置好casFilter,以拦截请求(不拦截自己的登陆页面)。调用CAS的Restful接口,可以自己写代码调用CAS的Restful接口,也可以使用官方推荐的pac4j插件,本文使用pac4j来进行服务调用。

  • maven项目的pom中增加如下依赖:
        <dependency>
            <groupId>org.pac4jgroupId>
            <artifactId>pac4j-casartifactId>
            <version>2.1.0version>
        dependency>
  • 编写返回登录页面的Controller:
@Controller
public class RestLoginController {

    @RequestMapping("/restLogin")
    public String index(Map model) {
        return "/restLogin/login";
    }
}
  • 编写Rest接口供页面表单提交数据:
    private final static String CAS_LOGIN_URL = "https://cas.dhcc.com:8443/cas/login";
    private final static String APP_URL = "http://localhost:8010/um/login/cas";

    @PostMapping(value="/restLogin")
    public void restLogin(HttpServletRequest request,HttpServletResponse response) {
        final CasConfiguration casConfiguration = new CasConfiguration(CAS_LOGIN_URL);
        CasRestFormClient client = new CasRestFormClient();
        client.setConfiguration(casConfiguration);

        WebContext webContext = new J2EContext(request, response);

        try {
            UsernamePasswordCredentials credentials = client.getCredentials(webContext);
            CasRestProfile profile = client.getUserProfile(credentials, webContext);
            String redirectUrl=CAS_LOGIN_URL+"?service="+APP_URL+"&tgc="+profile.getTicketGrantingTicketId();
            response.sendRedirect(redirectUrl);
        } catch (HttpAction | IOException e) {
            e.printStackTrace();
        }
    }
  • 登陆页面表单:
<form action="<%=request.getContextPath()%>/restLogin" id="loginForm" method="POST">
        <table>
            <tr>
                <td>用户名:td>
                <td><input id="username" name="username" type="text">td>
            tr>
            <tr>
                <td>密 码:td>
                <td><input id="password" name="password" type="password">td>
            tr>
            <tr>
                <td><input type="submit" value="登录" >td>
                <td><input type="reset">td>
            tr>
        table>
    form>

在此页面登陆后,再访问CAS的登陆页面,会发现是已登陆状态;但先在CAS的登陆页面登陆,再访问业务系统主页,会发现仍然被拦截到了登陆页面。这是为什么呢?因为我们配置了访问业务系统主页,若检查到没登陆,则被拦截到自定义的登陆页面,缺少了去CAS的登陆页面的cookie中检查TGC的流程。若配置为检查到未登陆,拦截到CAS的登陆页面则达不到使用自定义登录页面的目的。那么怎么解决这个问题呢?可以在获取登录页面的流程上进行修改,访问登录页面的地址时,先重定向到CAS的登陆页面,并带上一个值为登录地址的参数:若有TGC,则会进行单点登录流程;若没有TGC,让CAS再重定向到参数中的登录地址,并附带一个已检查过单点登录cookie的参数以避免循环重定向。
需要修改的内容如下:

  • CAS服务端

通过堆栈信息找到是在RankedAuthenticationProviderWebflowEventResolver类的resolveInternal方法中处理TGT的,修改此方法中TGT为null及认证失败时的逻辑,判断是否有loginUrl,如果有就跳转到loginUrl指明的地址,代码如下:

    @Override
    public Set resolveInternal(final RequestContext context) {
......
        if (StringUtils.isBlank(tgt)) {
            redirectToLoginUrl(context);
            LOGGER.trace("TGT is blank; proceed with flow normally.");
            return resumeFlow();
        }
        final Authentication authentication = this.ticketRegistrySupport.getAuthenticationFrom(tgt);
        if (authentication == null) {
            redirectToLoginUrl(context);
            LOGGER.trace("TGT has no authentication and is blank; proceed with flow normally.");
            return resumeFlow();
        }
......
    }

    /**
     * 
     * 重定向到request参数中指明的登陆页面
     * @param context
     */
    private void redirectToLoginUrl(final RequestContext context) {
        HttpServletRequest request=WebUtils.getHttpServletRequest();
        String loginUrl=request.getParameter("loginUrl");

        if (loginUrl!=null) {
            HttpServletResponse response = WebUtils.getHttpServletResponse(context);
            try {
                response.sendRedirect(loginUrl+"?checked=1");
            } catch (IOException e) {
                throw new DhccCasException(e.getMessage(),e);
            }
        }
    }

同理,我们可以参照RankedAuthenticationProviderWebflowEventResolver类写一个自定义的类DhccRankedAuthenticationProviderWebflowEventResolver,然后修改对应的方法,找到配置类CasCoreWebflowConfiguration,修改方法rankedAuthenticationProviderWebflowEventResolver使用我们自定义的类,如下所示:

    @ConditionalOnMissingBean(name = "rankedAuthenticationProviderWebflowEventResolver")
    @Bean
    @RefreshScope
    public CasWebflowEventResolver rankedAuthenticationProviderWebflowEventResolver() {
        return new DhccRankedAuthenticationProviderWebflowEventResolver(authenticationSystemSupport,
                centralAuthenticationService, servicesManager,
                ticketRegistrySupport, warnCookieGenerator,
                authenticationRequestServiceSelectionStrategies,
                selector, authenticationContextValidator,
                initialAuthenticationAttemptWebflowEventResolver());
    }
  • CAS客户端:

修改返回登录页面的Controller:

@Controller
public class RestLoginController {

    private final static String LOGIN_PAGE_PATH = "/restLogin/login";
    private final static String CAS_LOGIN_URL = "https://cas.dhcc.com:8443/cas/login";
    private final static String APP_SERVICE_URL = "http://localhost:8010/um/login/cas";
    private final static String APP_LOGIN_URL = "http://localhost:8010/um/restLogin";

    @RequestMapping("/restLogin")
    public ModelAndView index(HttpServletRequest request) {
        String checked = request.getParameter("checked");
        // 如果已重定向到cas的登录页面检查过tgc,那么直接返回当前登录页面。
        if ("1".equals(checked)) {
            return new ModelAndView(LOGIN_PAGE_PATH);
        }

        // 如果还没重定向到cas的登录页面检查tgc,那么重定向到cas的登录页面检查tgc。
        String redirectUrl = "redirect:" + CAS_LOGIN_URL + "?service=" + APP_SERVICE_URL + "&loginUrl="+APP_LOGIN_URL;

        return new ModelAndView(redirectUrl);
    }
}

修改后,先在自定义页面登陆,再访问CAS的登陆页面,会发现是已登陆状态;先在CAS的登陆页面登录,再访问自定义的登陆页面,会发现是已登陆状态。说明使用CAS的Restful接口实现了单点登陆。

你可能感兴趣的:(cas,sso)