上一篇文章简单介绍了 CAS 5.2.2 在本地开发环境中搭建服务端和客户端,对单点登陆过程有了一个直观的认识以后,本篇将探讨 CAS 单点登陆的实现原理。html
HTTP 是无状态协议,客户端与服务端之间的每一次通信都是独立的,而会话机制可让服务端鉴别每次通信过程当中的客户端是不是同一个,从而保证业务的关联性。Session 是服务器使用一种相似于散列表的结构,用来保存用户会话所须要的信息。Cookie 做为浏览器缓存,存储 Session ID 以到达会话跟踪的目的。java
因为 Cookie 的跨域策略限制,Cookie 携带的会话标识没法在域名不一样的服务端之间共享。
所以引入 CAS 服务端做为用户信息鉴别和传递中介,达到单点登陆的效果。git
官方流程图,地址:https://apereo.github.io/cas/...github
浏览器与 APP01 服务端web
浏览器与 APP02 服务端ajax
以客户端拦截器做为入口,对于用户请求,若是是已经校验经过的,直接放行:
org.jasig.cas.client.authentication.AuthenticationFilter#doFilterexpress
// 不进行拦截的请求地址 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#doFiltersegmentfault
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("<?xml version='1.0' encoding='UTF-8'?>"); writer.write(String.format("<partial-response><redirect url=\"%s\"></redirect></partial-response>", potentialRedirectUrl)); } else { response.sendRedirect(potentialRedirectUrl); } }
若是请求中带有 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 文件内容示例:
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'> <cas:authenticationSuccess> <cas:user>casuser</cas:user> <cas:attributes> <cas:credentialType>UsernamePasswordCredential</cas:credentialType> <cas:isFromNewLogin>true</cas:isFromNewLogin> <cas:authenticationDate>2018-03-25T22:09:49.768+08:00[GMT+08:00]</cas:authenticationDate> <cas:authenticationMethod>AcceptUsersAuthenticationHandler</cas:authenticationMethod> <cas:successfulAuthenticationHandlers>AcceptUsersAuthenticationHandler</cas:successfulAuthenticationHandlers> <cas:longTermAuthenticationRequestTokenUsed>false</cas:longTermAuthenticationRequestTokenUsed> </cas:attributes> </cas:authenticationSuccess> </cas:serviceResponse>
最后将 XML 字符串转换为对象 org.jasig.cas.client.validation.Assertion,并存储在 Session 或 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();
服务端采用了 Spirng Web Flow,以 login-webflow.xml 为入口:
<action-state id="realSubmit"> <evaluate expression="authenticationViaFormAction"/> <transition on="warn" to="warn"/> <transition on="success" to="sendTicketGrantingTicket"/> <transition on="successWithWarnings" to="showAuthenticationWarningMessages"/> <transition on="authenticationFailure" to="handleAuthenticationFailure"/> <transition on="error" to="initializeLoginForm"/> </action-state>
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<MessageDescriptor> list = new ArrayList<>(); return createHandlerResult(credential, this.principalFactory.createPrincipal(username), list); }
在 login-webflow.xml 中定义了 Ticket 校验流程:
<action-state id="ticketGrantingTicketCheck"> <evaluate expression="ticketGrantingTicketCheckAction"/> <transition on="notExists" to="gatewayRequestCheck"/> <transition on="invalid" to="terminateSession"/> <transition on="valid" to="hasServiceCheck"/> </action-state>
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<T>)
对于从 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();
最后将用户信息返回给客户端。