关于CAS的认证原理,网上已有很多相关文章了,官方文档也有说明,这里就不再详述了。本文重在阐述使用Apereo Cas 5.1.3遇到的与TGC相关的问题,及问题的分析解决过程。
遇到的TGC相关的问题:
- 由于需要使用云环境,把cas部署在了华为云上,配置了负载均衡(未启用负载均衡的会话保持),结果登陆状态无法保持,经过查看cas的日志,发现第二次访问的时候,请求的源IP与第一次访问的源IP不一致,被CAS拒绝了。华为云上的负载均衡器是一个集群,对内有多个内网IP,导致出现该问题,启用ELB的会话保持,暂时避开了此问题。
- 为了实现CAS集群,实现CAS无状态化,需要把CAS的session、TGT存在外部缓存redis中。实现之后,发现浏览器端的Cookie有一个TGC的值,会话状态是靠TGC保持的(即使删除SessionID,只要TGC存在就能保持登录状态)。在redis中却没发现TGC的踪迹。TGC还存储在CAS程序中?如果是,那么会话状态还是在应用实例中保持的,没有实现将所有状态信息放到程序外部。
- 一些业务系统需要有自己的登陆页面,在自己的登陆页面中调用CAS的Restful接口进行登录,同时又希望登录后,再访问其他使用CAS单点登录的系统时,不用再次登录。使用CAS的Restful接口进行登录认证倒容易实现,但再访问其他使用CAS单点登录的系统时,不用再次登录却没法按照CAS官方资料实现。经过分析,主要是由于业务系统使用自己的登陆页面登陆后,没有在CAS的域下写入TGC的Cookie值。要解决这个问题就需要弄清楚TGC的生成及使用逻辑,不得不去了解TGC的来龙去脉了。
解决问题的过程:
通过堆栈信息,顺利找到了CAS页面登录,表单提交的Action : SendTicketGrantingTicketAction
,顺藤摸瓜,在doExecute
方法中找到了调用CookieRetrievingCookieGenerator
的addCookie
相关方法:
this.ticketGrantingTicketCookieGenerator.addCookie(request, response, ticketGrantingTicketId);
在addCookie方法中找到了构造cookie的相关方法:
final String theCookieValue = this.casCookieValueManager.buildCookieValue(cookieValue, request);
CookieValueManager
有两个实现类:DefaultCasCookieValueManager
和 NoOpCookieValueManager
。在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.cipherEnabled
为false
)。那么怎么把TGC信息写入到客户端浏览器的Cookie中呢?考虑到业务系统可能和CAS不在一个域下,那么就在业务系统获取到TGT后,重定向到CAS的登陆页面(需要带上service参数,以便在CAS页面验证通过后重定向回业务系统页面),TGC作为一个参数传递到CAS的登陆页面,当浏览器使用登陆页面的地址获取登陆页面时,向后端发送请求,由CAS的单点登陆原理可知,浏览器向后端请求登陆页面时,肯定会查询cookie信息,那么我们在CookieRetrievingCookieGenerator
的retrieveCookieValue
的方法中打印堆栈,查看相关的逻辑。可以在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
类创建一个自定义的类然后再按照上述内容修改,然后修改配置类CasSupportActionsConfiguration
的initialFlowSetupAction
方法,改为使用自定义的类来创建对象就行了。
客户端应用程序调用CAS的Restful接口的实现:
客户端应用程序需要先配置好casFilter,以拦截请求(不拦截自己的登陆页面)。调用CAS的Restful接口,可以自己写代码调用CAS的Restful接口,也可以使用官方推荐的pac4j插件,本文使用pac4j来进行服务调用。
- maven项目的pom中增加如下依赖:
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-cas</artifactId>
<version>2.1.0</version>
</dependency>
- 编写返回登录页面的Controller:
@Controller
public class RestLoginController {
@RequestMapping("/restLogin")
public String index(Map<String, Object> 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<Event> 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接口实现了单点登陆。