CAS 总结之单点退出篇
CAS 到底有没有实现单点退出?本人阅读了 JA-SIG CAS v3.3 ,以及 JA-SIG CAS-CLIENT 3.1.9 的源代码,发现表面上好像实现了单点退出,但实际上却没有真正实现。
现将 CAS 的 logout 接口的实现整理如下。
首先看一下 CAS logout 功能的序列图。
CAS logout 功能的序列图
从图中可以看出, CAS logout 功能有两步,一是调用 TGT 对象中各个 Service 的 logoutOfService 方法,二是在缓存中清除 TGT 对象。
我们看一下 CAS 的 AbstractWebApplicationService 中 logoutOfService 方法的实现。
public synchronized boolean logOutOfService(final String sessionIdentifier) {
if (this.loggedOutAlready) {
return true;
}
LOG.debug("Sending logout request for: " + getId());
final String logoutRequest = "<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\""
+ GENERATOR.getNewTicketId("LR")
+ "\" Version=\"2.0\" IssueInstant=\"" + SamlUtils.getCurrentDateAndTime()
+ "\"><saml:NameID xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">@NOT_USED@</saml:NameID><samlp:SessionIndex>"
+ sessionIdentifier + "</samlp:SessionIndex></samlp:LogoutRequest>";
this.loggedOutAlready = true;
if (this.httpClient != null) {
return this.httpClient.sendMessageToEndPoint(getOriginalUrl(), logoutRequest);
}
return false;
}
另外 HttpClient 类中 sendMessageToEndPoint 方法的实现如下:
public boolean sendMessageToEndPoint(final String url, final String message) {
HttpURLConnection connection = null;
BufferedReader in = null;
try {
if (log.isDebugEnabled()) {
log.debug("Attempting to access " + url);
}
final URL logoutUrl = new URL(url);
final String output = "logoutRequest=" + URLEncoder.encode(message, "UTF-8");
connection = (HttpURLConnection) logoutUrl.openConnection();
connection.setDoInput(true);
connection.setDoOutput(true);
connection.setReadTimeout(this.readTimeout);
connection.setConnectTimeout(this.connectionTimeout);
connection.setRequestProperty("Content-Length", ""
+ Integer.toString(output.getBytes().length));
connection.setRequestProperty("Content-Type",
"application/x-www-form-urlencoded");
final DataOutputStream printout = new DataOutputStream(connection
.getOutputStream());
printout.writeBytes(output);
printout.flush();
printout.close();
in = new BufferedReader(new InputStreamReader(connection
.getInputStream()));
while (in.readLine() != null) {
// nothing to do
}
if (log.isDebugEnabled()) {
log.debug("Finished sending message to" + url);
}
return true;
} catch (final Exception e) {
log.error(e,e);
return false;
} finally {
if (in != null) {
try {
in.close();
} catch (final IOException e) {
// can't do anything
}
}
if (connection != null) {
connection.disconnect();
}
}
}
通过阅读代码可以发现, logOutOfService 方法是调用 serivce 的 originUrl 接口,利用 HttpURLConnection 的方式把退出请求发送给 service ,注意没有给 HttpURLConnection 设置 requestMethod ,因此用的是默认的 GET 方法。 sessionIdentifier 的值是 ST 的值。 service 在 response 中会解析 logoutRequest 参数中的 sessionIdentifier 的值,然后把 sessionIdentifier 标识的 session kill 掉就可以了。这时我们发现,原理上是可以单点退出的。
再来看客户端的实现,客户端和单点退出有关的类包括:
- SingleSignOutFilter :用来解析 logoutRequest 参数。
- SessionMappingStorage :一个接口,定义了 Session 存储器的方法。
- HashMapBackedSessionMappingStorage : Session 存储器的实现类,定义了 2 个 Map 来存储 Session 。
MANAGED_SESSIONS:key 为 ST 的值, value 为 session ;
ID_TO_SESSION_KEY_MAPPING : key 为 sessionId,value 为 ST 的值。
- SingleSignOutHttpSessionListener :此 Listener 监听到 session destroy 的事件后,用 sessionId 从上述 ID_TO_SESSION_KEY_MAPPING 中取出 ST 的值,然后依据 ST 的值从 MANAGED_SESSIONS 中取出 session, 然后就可以执行其 invalidate 方法了。
我们看一下 SingleSignOutFilter 中的 doFilter 方法。
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;
if ("POST".equals(request.getMethod())) {
final String logoutRequest = CommonUtils.safeGetParameter(request, "logoutRequest");
if (CommonUtils.isNotBlank(logoutRequest)) {
if (log.isTraceEnabled()) {
log.trace ("Logout request=[" + logoutRequest + "]");
}
final String sessionIdentifier = XmlUtils.getTextForElement(logoutRequest, "SessionIndex");
if (CommonUtils.isNotBlank(sessionIdentifier)) {
final HttpSession session = SESSION_MAPPING_STORAGE.removeSessionByMappingId(sessionIdentifier);
if (session != null) {
String sessionID = session.getId();
if (log.isDebugEnabled()) {
log.debug ("Invalidating session [" + sessionID + "] for ST [" + sessionIdentifier + "]");
}
try {
session.invalidate();
} catch (final IllegalStateException e) {
log.debug(e,e);
}
}
return;
}
}
} else {
final String artifact = CommonUtils.safeGetParameter(request, this.artifactParameterName);
final HttpSession session = request.getSession(false);
if (session != null) {
if (log.isDebugEnabled()) {
log.debug("Storing session identifier for " + session.getId());
}
if (CommonUtils.isNotBlank(artifact)) {
try {
SESSION_MAPPING_STORAGE.removeBySessionById(session.getId());
} catch (final Exception e) {
// ignore if the session is already marked as invalid. Nothing we can do!
}
SESSION_MAPPING_STORAGE.addSessionById(artifact, session);
}
} else {
log.debug("No Session Found, so ignoring.");
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
非常奇怪,这个方法首先判断了 request 的方法,如果是 POST, 则会执行 session.invalidate 方法,从而实现单点退出,如果是 GET ,则只会在存在 ticket 参数的情况下,把 session 存进 SessionMappingStorage ,永远也不执行 session.invalidate 方法,不能单点退出。因为 CAS 的 logoutRequest 请求是用 GET 方法发过来的,所以,单点登录功能没有实现。
本人对 SingleSignOutFilter 中的 doFilter 方法重写了一下,代码如下。经验证,确实实现了单点退出。
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;
final String logoutRequest = CommonUtils.safeGetParameter(request, "logoutRequest");
Enumeration ff = request.getParameterNames();
String a = request.getQueryString();
if (CommonUtils.isNotBlank(logoutRequest)) {
final String sessionIdentifier = XmlUtils.getTextForElement(logoutRequest, "SessionIndex");
if (CommonUtils.isNotBlank(sessionIdentifier)) {
final HttpSession session = SESSION_MAPPING_STORAGE.removeSessionByMappingId(sessionIdentifier);
if (session != null) {
String sessionID = session.getId();
try {
session.invalidate();
} catch (final IllegalStateException e) {
}
}
}
}
else{
final String artifact = CommonUtils.safeGetParameter(request, this.artifactParameterName);
final HttpSession session = request.getSession(false);
if (CommonUtils.isNotBlank(artifact) && session!=null) {
try {
SESSION_MAPPING_STORAGE.removeBySessionById(session.getId());
} catch (final Exception e) {
}
SESSION_MAPPING_STORAGE.addSessionById(artifact, session);
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
另外还需要注意的是,因为客户端部署了三个 Filter:AuthenticationFilter 、 ServiceValidationFilter 、 SingleSignOutFilter ,所以三个 Filter 的顺序需要注意,我的顺序为 AuthenticationFilter 、 ServiceValidationFilter 、 SingleSignOutFilter ,一开始不行,因为执行退出功能时, CAS 服务端用 HttpURLConnection 访问客户端,没有把 sessionId 代过来,所以在 AuthenticationFilter 中就被 redirect 回 CAS 了,到不了 SingleSignOutFilter ,我做了一个改动,就是在 AuthenticationFilter 中的 redirectUrl ,后面加上了 session ID 的值,格式如:“ ;jsessionid= ” , 这样 CAS 端解析 service 参数生成 WebApplicationService 时, orginUrl 里就有 sessionId 了。
虽然这样就实现了单点退出,但我感觉 CAS 的这种采用 Filter 的方式太麻烦了,不如让客户应用提供一个 callback url ,CAS 直接调用这个 callback url 来退出更好一些,但这样的话,对 CAS 的改动非常大。
本人博客 :http://zhenkm0507.iteye.com