0%

CAS详细认证流程和部分客户端源码解析

简介

CAS解决了单点登录的问题,网上有很多关于CAS是如何实现单点的文章,但是很少有对详细认证流程做解释,这里做一下梳理。

核心概念

  • TGC:Ticket Granting Cookie
    CAS 会将生成的 TGT 放在 session(默认,实际项目放在redis中这里只是举例) 中,而 TGC 就是这个 session 的唯一标识(sessionId),可以认为是 TGT 的key,为 TGT 就是 TGC 的 value,TGC 以 cookie 的形式保存在浏览器中,每次访问单点域名都会尝试携带 TGC。(每个服务都会在 session 和 cookie 中保存对应的 TGT 和 TGC)
  • TGT:Ticket Granting Ticket
    TGT 是CAS 为用户签发的登录 ticket,也是用于验证用户登录成功的唯一方式。 TGT 封装了用户信息,CAS 通过 Cookie 值(TGC)为 key 查询缓存中有无 TGT(TGC:TGT(key:value)),如果有的话就说明用户已经登录成。
  • ST:Service Ticket
    ST 是当用户访问某一服务时提供的 ticket。用户在访问其他服务时,发现没有 登录 ,那么就会302到 CAS 服务器获取 ST。然后会携带着 ST 302 回来,本地应用根据ST去CAS获取登录的用户信息,并且保存在本地应用中。

环境准备

我们这里准备了3台节点来测试,1和2服务分别对应接入单点的2个系统,我们开启这3个服务

序号 域名 作用
1 app1.com 应用1
2 app2.com 应用2
3 localhost/cas CAS单点登录服务器

第一次访问app1

我们访问app1下的接口http://app1.com:8181/book/books ,由于此时浏览器第一次访问app1,之前并没登录,在CAS的client中会做一次跳转,将我们访问的地址重新定向到cas单点登录服务器,我们可以看到此时浏览器一共做了2次操作,第一次如下图,服务器返回302,在respone中返回Location:来标明接下来要重定向的地址,在url中带了一个service的参数,这个参数用来标明在Cas认证完成后,要调转回原来服务的地址
图片
接下来浏览器根据上一步浏览器返回的地址进行跳转,跳转到CAS服务器,进行账号密码认证
图片

第一次认证

接上上一步,我们在CAS中输入账号和密码,我们可以看到浏览器进行了如下操作
提交账号密码并返回302进行重定向,我们可以注意一下在重定向的Location:中返回了一个临时票据ST,并且我们注意到当前在CAS服务器的这个域名下存入了cookieTGC,这里是跨域完成单点登录最重要的一步,因为传统模式下cookie是不能跨域的,但是我们单点登录不同系统往往是跨域名的,如果只将cookie存在当前系统下的不行的,因为不同域不能访问不同的cookie,于是CAS投机取巧的把cookie存放在了CAS所在的域名下,所有子系统在浏览器第一次访问时都要跳转的CAS服务器,那么这时候CAS就可以获取CAS服务端下的cookie,那么这时候CAS服务器只要查看本域名下时候有cookie就知道用户是否已经登陆过,如果登陆了再把信息存入到各个系统的session中,这样就不要每次访问都经过cas服务器,具体原理我们在下面查看
图片

图片

访问重定向的地址,其中地址中带上ST临时票据来进行安全认证,并且返回302,重新定向到我们最开始访问的地址
图片

最后访问我们最开始要访问的地址
图片

这时我们发现请求中已经没有带上任何cookie的信息了,那么CAS是如何知道我们已经完成过登录验证了呢?因为我们使用的CAS提供的client包已经将SESSION_ID存入服务器的SESSION,这里的原理就和我们使用SESSION来做登录原理一样了。
我们来查看一下具体源码来证实一下我们的想法
CAS客户端主要是由Servlet拦截器去实现的,我们在上一篇提到的博文中在客户端中注册了Cas30ProxyReceivingTicketValidationFilter拦截器和AuthenticationFilter拦截器,而且规定Cas30ProxyReceivingTicketValidationFilter拦截器必须要在AuthenticationFilter的前面,这是为什么呢?因为前者是负责验证ticket的或者是用来做认证的(没有登录的话跳转到CAS服务器),前者认证完ticket会把验证对象放入到session中,如果AuthenticationFilter发现session中存在验证对象则跳过拦截器。
我们查看一下Cas30ProxyReceivingTicketValidationFilter拦截器的父类AbstractTicketValidationFilter,其中的doFilter方法实现了拦截器的主体逻辑,下面我的注释已经写出最主要的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public abstract class AbstractTicketValidationFilter extends AbstractCasFilter {


public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//此方法验证拦截器的前置条件在Cas30ProxyReceivingTicketValidationFilter中实现的是代理前置过滤,如果拦截器设置了代理地址则不进入拦截器
if (this.preFilter(servletRequest, servletResponse, filterChain)) {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
String ticket = this.retrieveTicketFromRequest(request);
if (CommonUtils.isNotBlank(ticket)) {
this.logger.debug("Attempting to validate ticket: {}", ticket);

try {
//此部分为远程调用CAS服务端接口来验证ticket是否合法,如果合法生成Assertion验证对象
Assertion assertion = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
this.logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());
request.setAttribute("_const_cas_assertion_", assertion);
//将验证对象存入session
if (this.useSession) {
request.getSession().setAttribute("_const_cas_assertion_", assertion);
}

this.onSuccessfulValidation(request, response, assertion);
if (this.redirectAfterValidation) {
this.logger.debug("Redirecting after successful ticket validation.");
response.sendRedirect(this.constructServiceUrl(request, response));
return;
}
} catch (TicketValidationException var8) {
this.logger.debug(var8.getMessage(), var8);
this.onFailedValidation(request, response);
if (this.exceptionOnValidationFailure) {
throw new ServletException(var8);
}

response.sendError(403, var8.getMessage());
return;
}
}

filterChain.doFilter(request, response);
}
}

}

上面我们说到Cas30ProxyReceivingTicketValidationFilter必须要在AuthenticationFilter拦截器之前,我们现在看下AuthenticationFilter拦截器又做了什么最主要的逻辑就是从session中获取验证过滤器中放入的Assertion对象,如果存在则认为已经登陆过,没有则跳转到CAS服务器去登陆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class AuthenticationFilter extends AbstractCasFilter {


public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
//如果跳过验证,白名单
if (this.isRequestUrlExcluded(request)) {
this.logger.debug("Request is ignored.");
filterChain.doFilter(request, response);
} else {
HttpSession session = request.getSession(false);
Assertion assertion = session != null ? (Assertion)session.getAttribute("_const_cas_assertion_") : null;
//如果session中存在值则跳出拦截器,认为此次访问已经登陆过
if (assertion != null) {
filterChain.doFilter(request, response);
} else {
//如果session中没有值重定向到CAS服务器去登陆
String serviceUrl = this.constructServiceUrl(request, response);
String ticket = this.retrieveTicketFromRequest(request);
boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
if (!CommonUtils.isNotBlank(ticket) && !wasGatewayed) {
this.logger.debug("no ticket and no assertion found");
String modifiedServiceUrl;
if (this.gateway) {
this.logger.debug("setting gateway attribute in session");
modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
} else {
modifiedServiceUrl = serviceUrl;
}

this.logger.debug("Constructed service url: {}", modifiedServiceUrl);
String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
} else {
filterChain.doFilter(request, response);
}
}
}
}



}

接下来我们第一次访问APP2应用来证实我们上面所说的流程

在我们已经登陆过app1的情况下我们访问app2 http://app2.com:8282/book/books ,我们看到服务器发出302请求跳转到CAS服务端

图片

接下来浏览器自动跳转到CAS服务器的域名,这里我们看到这里带上了CAS域名localhost下的cookie
图片

这时CAS就用这个cookie从服务端中查找,发现已经登陆过了,继续发出302请求,并且在Location: 跳转地址中在url中下发st
图片

接下来又下发302请求要求浏览器使用st去验证时候是本人发起的访问,如果验证成功在respone中重新定向到我们之前要访问的地址
图片

最后完成访问,这时候session_id已经存入到app2的session中,所以后续的访问不会再经过上面的逻辑,直接从App2服务端中获取session中就知道应用时候已经登陆
图片

ticket时效总结

由上述论证我们可以得到ticket时效,如果ticket(TGT)在CAS服务端中过期,但是应用中session没有过期,仍然有效的话,那么应用仍然可以登录,如果应用中session时效的话,CAS的ticket(TGT)未过期,那么应用仍然可以通过上述方式重新生成session,只有在CAS中的ticket和应用中的session同时失效的情况下,才需要重新登录