上一篇文章简单介绍了 CAS 5.2.2 在本地开发环境中搭建服务端和客户端,对单点登录过程有了一个直观的认识之后,本篇将探讨 CAS 单点登录的实现原理。
一、Session 和 Cookie
HTTP 是无状态协议,客户端与服务端之间的每一次通讯都是独立的,而会话机制可以让服务端鉴别每次通讯过程中的客户端是否是同一个,从而保证业务的关联性。Session 是服务器使用一种类似于散列表的结构,用来保存用户会话所需要的信息。Cookie 作为浏览器缓存,存储 Session ID 以到达会话跟踪的目的。
由于 Cookie 的跨域策略限制,Cookie 携带的会话标识无法在域名不同的服务端之间共享。
因此引入 CAS 服务端作为用户信息鉴别和传递中介,达到单点登录的效果。
二、CAS 流程图
官方流程图,地址:https://apereo.github.io/cas/...
浏览器与 APP01 服务端
- 浏览器第一次访问受保护的 APP01 服务端,由于未经授权而被拦截并重定向到 CAS 服务端。
- 浏览器第一次与 CAS 服务端通讯,鉴权成功后由 CAS 服务端创建全局会话 SSO Session,生成全局会话标识 TGT 并存储在浏览器 Cookie 中。
- 浏览器重定向到 APP01,重写 URL 地址带上全局会话标识 TGT。
- APP01 拿到全局会话标识 TGT 后向 CAS 服务端请求校验,若校验成功,则 APP01 会获取到已经登录的用户信息。
- APP01 创建局部会话 Session,并将 SessionID 存储到浏览器 Cookie 中。
- 浏览器与 APP01 建立会话。
浏览器与 APP02 服务端
- 浏览器第一次访问受保护的 APP02 服务端,由于未经授权而被拦截并重定向到 CAS 服务端。
- 浏览器第二次与 CAS 服务端通讯,CAS 校验 Cookie 中的全局会话标识 TGT。
- 浏览器重定向到 APP02,重写 URL 地址带上全局会话标识 TGT。
- APP02 拿到全局会话标识 TGT 后向 CAS 服务端请求校验,若校验成功,则 APP02 会获取到已经登录的用户信息。
- APP02 创建局部会话 Session,并将 SessionID 存储到浏览器 Cookie 中。
- 浏览器与 APP02 建立会话。
三、相关源码
3.1 CAS客户端
3.1.1 根据是否已登录进行拦截跳转
以客户端拦截器作为入口,对于用户请求,如果是已经校验通过的,直接放行:
org.jasig.cas.client.authentication.AuthenticationFilter#doFilter
// 不进行拦截的请求地址
if (isRequestUrlExcluded(request)) {
logger.debug("Request is ignored.");
filterChain.doFilter(request, response);
return;
}
// Session已经登录
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;
}
// 从请求中获取ticket
final String serviceUrl = constructServiceUrl(request, response);
final String ticket = retrieveTicketFromRequest(request);
final boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
if (CommonUtils.isNotBlank(ticket) || wasGatewayed) {
filterChain.doFilter(request, response);
return;
}
否则进行重定向:
org.jasig.cas.client.authentication.AuthenticationFilter#doFilter
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
对于Ajax请求和非Ajax请求的重定向,进行分别处理:
org.jasig.cas.client.authentication.FacesCompatibleAuthenticationRedirectStrategy#redirect
public void redirect(final HttpServletRequest request, final HttpServletResponse response,
final String potentialRedirectUrl) throws IOException {
if (CommonUtils.isNotBlank(request.getParameter(FACES_PARTIAL_AJAX_PARAMETER))) {
// this is an ajax request - redirect ajaxly
response.setContentType("text/xml");
response.setStatus(200);
final PrintWriter writer = response.getWriter();
writer.write("");
writer.write(String.format(" ",
potentialRedirectUrl));
} else {
response.sendRedirect(potentialRedirectUrl);
}
}
3.1.2 校验Ticket
如果请求中带有 Ticket,则进行校验,校验成功返回用户信息:
org.jasig.cas.client.validation.AbstractTicketValidationFilter#doFilter
final Assertion assertion = this.ticketValidator.validate(ticket, constructServiceUrl(request, response));
logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());
request.setAttribute(CONST_CAS_ASSERTION, assertion);
打断点得知返回的信息为 XML 格式字符串:
org.jasig.cas.client.validation.AbstractUrlBasedTicketValidator#validate
logger.debug("Retrieving response from server.");
final String serverResponse = retrieveResponseFromServer(new URL(validationUrl), ticket);
XML 文件内容示例:
casuser
UsernamePasswordCredential
true
2018-03-25T22:09:49.768+08:00[GMT+08:00]
AcceptUsersAuthenticationHandler
AcceptUsersAuthenticationHandler
false
最后将 XML 字符串转换为对象 org.jasig.cas.client.validation.Assertion,并存储在 Session 或 Request 中。
3.1.3 重写Request请求
定义过滤器:
org.jasig.cas.client.util.HttpServletRequestWrapperFilter#doFilter
其中定义 CasHttpServletRequestWrapper,重写 HttpServletRequestWrapperFilter:
final class CasHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final AttributePrincipal principal;
CasHttpServletRequestWrapper(final HttpServletRequest request, final AttributePrincipal principal) {
super(request);
this.principal = principal;
}
public Principal getUserPrincipal() {
return this.principal;
}
public String getRemoteUser() {
return principal != null ? this.principal.getName() : null;
}
// 省略其他代码
这样使用以下代码即可获取已登录用户信息。
AttributePrincipal principal = (AttributePrincipal) request.getUserPrincipal();
3.2 CAS服务端
3.2.1 用户密码校验
服务端采用了 Spirng Web Flow,以 login-webflow.xml 为入口:
action-state
代表一个流程,其中 id 为该流程的标识。evaluate expression
为该流程的实现类。transition
表示对返回结果的处理。
定位到该流程对应的实现类authenticationViaFormAction
,可知在项目启动时实例化了对象AbstractAuthenticationAction
:
@ConditionalOnMissingBean(name = "authenticationViaFormAction")
@Bean
@RefreshScope
public Action authenticationViaFormAction() {
return new InitialAuthenticationAction(initialAuthenticationAttemptWebflowEventResolver,
serviceTicketRequestWebflowEventResolver,
adaptiveAuthenticationPolicy);
}
在页面上点击登录按钮,进入:
org.apereo.cas.web.flow.actions.AbstractAuthenticationAction#doExecute
org.apereo.cas.authentication.PolicyBasedAuthenticationManager#authenticate
经过层层过滤,得到执行校验的AcceptUsersAuthenticationHandler
和待校验的UsernamePasswordCredential
。
执行校验,进入
org.apereo.cas.authentication.AcceptUsersAuthenticationHandler#authenticateUsernamePasswordInternal
@Override
protected HandlerResult authenticateUsernamePasswordInternal(final UsernamePasswordCredential credential,
final String originalPassword) throws GeneralSecurityException {
if (this.users == null || this.users.isEmpty()) {
throw new FailedLoginException("No user can be accepted because none is defined");
}
// 页面输入的用户名
final String username = credential.getUsername();
// 根据用户名取得缓存中的密码
final String cachedPassword = this.users.get(username);
if (cachedPassword == null) {
LOGGER.debug("[{}] was not found in the map.", username);
throw new AccountNotFoundException(username + " not found in backing map.");
}
// 校验缓存中的密码和用户输入的密码是否一致
if (!StringUtils.equals(credential.getPassword(), cachedPassword)) {
throw new FailedLoginException();
}
final List list = new ArrayList<>();
return createHandlerResult(credential, this.principalFactory.createPrincipal(username), list);
}
3.2.2 登录页Ticket校验
在 login-webflow.xml 中定义了 Ticket 校验流程:
org.apereo.cas.web.flow.TicketGrantingTicketCheckAction#doExecute
@Override
protected Event doExecute(final RequestContext requestContext) {
// 从请求中获取TicketID
final String tgtId = WebUtils.getTicketGrantingTicketId(requestContext);
if (!StringUtils.hasText(tgtId)) {
return new Event(this, NOT_EXISTS);
}
String eventId = INVALID;
try {
// 根据TicketID获取Tciket对象,校验是否失效
final Ticket ticket = this.centralAuthenticationService.getTicket(tgtId, Ticket.class);
if (ticket != null && !ticket.isExpired()) {
eventId = VALID;
}
} catch (final AbstractTicketException e) {
LOGGER.trace("Could not retrieve ticket id [{}] from registry.", e.getMessage());
}
return new Event(this, eventId);
}
可知 Ticket 存储在服务端的一个 Map 集合中:
org.apereo.cas.AbstractCentralAuthenticationService#getTicket(java.lang.String, java.lang.Class
3.2.3 客户端Ticket校验
对于从 CAS 客户端发送过来的 Ticket 校验请求,则会进入服务端以下代码:
org.apereo.cas.DefaultCentralAuthenticationService#validateServiceTicket
从 Ticket 仓库中,根据 TicketID 获取 Ticket 对象:
final ServiceTicket serviceTicket = this.ticketRegistry.getTicket(serviceTicketId, ServiceTicket.class);
在同步块中校验 Ticket 是否失效,以及是否来自合法的客户端:
synchronized (serviceTicket) {
if (serviceTicket.isExpired()) {
LOGGER.info("ServiceTicket [{}] has expired.", serviceTicketId);
throw new InvalidTicketException(serviceTicketId);
}
if (!serviceTicket.isValidFor(service)) {
LOGGER.error("Service ticket [{}] with service [{}] does not match supplied service [{}]",
serviceTicketId, serviceTicket.getService().getId(), service);
throw new UnrecognizableServiceForServiceTicketValidationException(serviceTicket.getService());
}
}
根据 Ticket 获取已登录用户:
final TicketGrantingTicket root = serviceTicket.getGrantingTicket().getRoot();
final Authentication authentication = getAuthenticationSatisfiedByPolicy(root.getAuthentication(),
new ServiceContext(selectedService, registeredService));
final Principal principal = authentication.getPrincipal();
最后将用户信息返回给客户端。