一.前期准备
1.cas源码版本
<version>4.1.11-SNAPSHOT</version>
2.服务端
http://www.eric.cas.server.com:8080/cas-server
3.cas client
http://www.eric.cas.client.com:8081/cas/index.do
4.cas client web.xml配置
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<display-name>Archetype Created Web Application</display-name>
<servlet>
<servlet-name>cas-client-4.x</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>cas-client-4.x</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<listener>
<listener-class>
org.springframework.web.context.ContextLoaderListener
</listener-class>
</listener>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>
classpath*:/applicationContext.xml
</param-value>
</context-param>
<!-- ****************** 单点登录开始 ********************-->
<!-- 用于实现单点登出功能 可选 -->
<listener>
<listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class>
</listener>
<filter>
<filter-name>CAS Single Sign Out Filter</filter-name>
<filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
<init-param>
<param-name>casServerUrlPrefix</param-name>
<param-value>http://www.eric.cas.server.com:8080/</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Single Sign Out Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 该过滤器负责用户的认证工作,必须 -->
<filter>
<filter-name>CAS Authentication Filter</filter-name>
<filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
<init-param>
<param-name>casServerLoginUrl</param-name>
<!--casServerLoginUrl:cas服务的登陆url -->
<param-value>http://www.eric.cas.server.com:8080/login</param-value>
</init-param>
<init-param>
<!--serverName:本项目的ip+port -->
<param-name>serverName</param-name>
<param-value>http://www.eric.cas.client.com:8081</param-value>
</init-param>
<init-param>
<param-name>useSession</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>redirectAfterValidation</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Authentication Filter</filter-name>
<url-pattern>/cas/*</url-pattern>
</filter-mapping>
<!-- 该过滤器负责对Ticket的校验工作,必须-->
<filter>
<filter-name>CAS Validation Filter</filter-name>
<filter-class>org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter</filter-class>
<init-param>
<param-name>casServerUrlPrefix</param-name>
<param-value>http://www.eric.cas.server.com:8080/</param-value>
</init-param>
<init-param>
<param-name>serverName</param-name>
<param-value>http://www.eric.cas.client.com:8081</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CAS Validation Filter</filter-name>
<!-- 对cas做登录拦截-->
<url-pattern>/cas/*</url-pattern>
</filter-mapping>
<!-- 该过滤器对HttpServletRequest请求包装, 可通过HttpServletRequest的getRemoteUser()方法获得登录用户的登录名,可选 -->
<filter>
<filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
<filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 该过滤器使得可以通过org.jasig.cas.client.util.AssertionHolder来获取用户的登录名。
比如AssertionHolder.getAssertion().getPrincipal().getName()。
这个类把Assertion信息放在ThreadLocal变量中,这样应用程序不在web层也能够获取到当前登录信息 -->
<filter>
<filter-name>CAS Assertion Thread Local Filter</filter-name>
<filter-class>org.jasig.cas.client.util.AssertionThreadLocalFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS Assertion Thread Local Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- ****************** 单点登录结束 ********************-->
</web-app>
登录流程
当从浏览器访问配置了单点登录的应用系统时(http://www.eric.cas.client.com:8081/cas/index.do),
请求第一次到达SingleSignOutFilter过滤器
一. SingleSignOutFilter
SingleSignOutFilter的doFilter方法如下:
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
if(!this.handlerInitialized.getAndSet(true)) {
HANDLER.init();
}
// 处理请求方法
if(HANDLER.process(request, response)) {
filterChain.doFilter(servletRequest, servletResponse);
}
}
可以看到SingleSignOutFilter中处理请求的主要方法是
HANDLER.process(request, response)
实际调用的是SingleSignOutHandler的process方法
public boolean process(HttpServletRequest request, HttpServletResponse response) {
// 是否是带有token参数的请求 如ticket参数
if(this.isTokenRequest(request)) {
this.logger.trace("Received a token request");
// 记录session
this.recordSession(request);
return true;
} else if(this.isBackChannelLogoutRequest(request)) {
// 登出请求 销毁session
this.logger.trace("Received a back channel logout request");
this.destroySession(request);
return false;
} else if(this.isFrontChannelLogoutRequest(request)) {
this.logger.trace("Received a front channel logout request");
// 登出请求 销毁session
this.destroySession(request);
String redirectionUrl = this.computeRedirectionToServer(request);
if(redirectionUrl != null) {
CommonUtils.sendRedirect(response, redirectionUrl);
}
return false;
} else {
this.logger.trace("Ignoring URI for logout: {}", request.getRequestURI());
return true;
}
}
以上源码可以看到,process方法主要是用于单点登录时记录登录的session信息,单点登出时,销毁记录的session。
由于是第一次请求,直接返回true,跳转到下一个过滤器AuthenticationFilter
二. AuthenticationFilter
AuthenticationFilter的doFilter方法如下:
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 {
//判断登录session是否存在
HttpSession session = request.getSession(false);
//从session中获取名为"_const_cas_assertion_"的Assertion
Assertion assertion = session != null?(Assertion)session.getAttribute("_const_cas_assertion_"):null;
if(assertion != null) {
//登录session存在 则执行下一个filter
filterChain.doFilter(request, response);
} else {
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.getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
} else {
//如果ticket不为空,本过滤器处理完成,处理下个过滤器
filterChain.doFilter(request, response);
}
}
}
}
由以上源码可知,当请求到达之后执行以下操作:
- 首先判断当前请求是否需要过滤,不需要则直接跳转到下一个过滤器。
- 从session中获取名为“const_cas_assertion”的assertion对象,判断assertion是否存在,如果存在,说明已经登录,执行下一个过滤器。如果不存在,执行第3步。
- 生成serviceUrl(http://www.eric.cas.client.com:8081/cas/index.do),并且从request中获取票据参数ticket,判断ticket是否为空,如果不为空执行下一个过滤器。如果为空,执行第4步。
- 生成重定向URL(http://www.eric.cas.server.com:8080/cas-server/login?service=http://www.eric.cas.client.com:8081/cas/index.do)。
- 执行重定向,跳转到单点登录服务器,显示登录页面
浏览器登录页面
- 登录页面输入用户名和密码,执行登录操作,请求到达cas server,cas server做登录校验之后,生成登录所需要的ticket并重定向到cas client,具体重定向地址如下
http://www.eric.cas.client.com:8081/cas/index.do?ticket=ST-1-alt4ccCXxjOamzWU4Hid-cas01.example.org
此时已经拿到了单点登录的临时票据Service Ticket,简称ST。
带有ST参数的请求重新到达cas client,还是先到达SingleSignOutFilter,此时已经拿到登录的ST,则保存登录的票据信息到session,然后执行下一个拦截器AuthenticationFilter
if(this.isTokenRequest(request)) {
this.logger.trace("Received a token request");
this.recordSession(request);
return true;
}
private void recordSession(HttpServletRequest request) {
HttpSession session = request.getSession(this.eagerlyCreateSessions);
if(session == null) {
this.logger.debug("No session currently exists (and none created). Cannot record session information for single sign out.");
} else {
String token = CommonUtils.safeGetParameter(request, this.artifactParameterName, this.safeParameters);
this.logger.debug("Recording session for token {}", token);
try {
this.sessionMappingStorage.removeBySessionById(session.getId());
} catch (Exception var5) {
;
}
this.sessionMappingStorage.addSessionById(token, session);
}
}
带有ST参数的请求到达AuthenticationFilter,此时assertion对象仍然为空但是ticket不为空,则直接跳转到下一个过滤器Cas20ProxyReceivingTicketValidationFilter
三. Cas20ProxyReceivingTicketValidationFilter
Cas20ProxyReceivingTicketValidationFilter继承了AbstractTicketValidationFilter类,doFilter方法如下
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
if(this.preFilter(servletRequest, servletResponse, filterChain)) {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
/从request中获取ST参数
String ticket = this.retrieveTicketFromRequest(request);
if(CommonUtils.isNotBlank(ticket)) {
this.logger.debug("Attempting to validate ticket: {}", ticket);
try {
//验证ticket并产生Assertion对象,错误抛出TicketValidationException异常
Assertion e = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
this.logger.debug("Successfully authenticated user: {}", e.getPrincipal().getName());
//验证成功 保存认证用户信息到session
request.setAttribute("_const_cas_assertion_", e);
if(this.useSession) {
request.getSession().setAttribute("_const_cas_assertion_", e);
}
this.onSuccessfulValidation(request, response, e);
//跳转到请求地址
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);
}
}
Cas20ProxyReceivingTicketValidationFilter,执行以下操作:
从request获取ticket参数,如果ticket为空,继续处理下一个过滤器。如果参数不为空,验证ticket参数的合法性。
验证ticket,TicketValidator的validate方法通过httpClient访问CAS服务器端
http://www.eric.cas.server.com:8080/serviceValidate?ticket=ST-6-okgjpFYrNhfVYbCx9xYQ-cas01.example.org&service=http%3A%2F%2Fwww.eric.cas.client.com%3A8081%2Fcas%2Findex.do
验证ticket是否正确,并返回assertion对象。
如果验证失败,抛出异常,跳转到错误页面。
如果验证成功,则将Assertion对象保存到session中,s,继续处理下一个过滤器。
request.getSession().setAttribute("_const_cas_assertion_", e);
单点登录流程结束
服务端具体验证ST流程
补充上面说的cas client请求cas server验证ticket的具体逻辑
验证请求如下:
http://www.eric.cas.server.com:8080/serviceValidate?ticket=ST-6-okgjpFYrNhfVYbCx9xYQ-cas01.example.org&service=http%3A%2F%2Fwww.eric.cas.client.com%3A8081%2Fcas%2Findex.do
查看服务端cas-servlet.xml文件配置
<bean
id="handlerMappingC"
class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping"
p:alwaysUseFullPath="true">
<property name="mappings">
<util:properties>
<prop key="/serviceValidate">serviceValidateController</prop>
<prop key="/proxyValidate">proxyValidateController</prop>
<prop key="/p3/serviceValidate">v3ServiceValidateController</prop>
<prop key="/p3/proxyValidate">v3ProxyValidateController</prop>
<prop key="/validate">legacyValidateController</prop>
<prop key="/proxy">proxyController</prop>
<prop key="/authorizationFailure.html">passThroughController</prop>
</util:properties>
</property>
</bean>
可以看到cas client发送的ST验证请求由serviceValidateController负责处理
@Override
protected final ModelAndView handleRequestInternal(final HttpServletRequest request, final HttpServletResponse response)
throws Exception {
final WebApplicationService service = this.argumentExtractor.extractService(request);
final String serviceTicketId = service != null ? service.getArtifactId() : null;
if (service == null || serviceTicketId == null) {
logger.debug("Could not identify service and/or service ticket for service: [{}]", service);
return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_REQUEST,
CasProtocolConstants.ERROR_CODE_INVALID_REQUEST, null);
}
try {
final Credential serviceCredential = getServiceCredentialsFromRequest(service, request);
TicketGrantingTicket proxyGrantingTicketId = null;
if (serviceCredential != null) {
try {
proxyGrantingTicketId = this.centralAuthenticationService.delegateTicketGrantingTicket(serviceTicketId,
serviceCredential);
logger.debug("Generated PGT [{}] off of service ticket [{}] and credential [{}]",
proxyGrantingTicketId.getId(), serviceTicketId, serviceCredential);
} catch (final AuthenticationException e) {
logger.info("Failed to authenticate service credential {}", serviceCredential);
} catch (final TicketException e) {
logger.error("Failed to create proxy granting ticket for {}", serviceCredential, e);
}
if (proxyGrantingTicketId == null) {
return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
new Object[] {serviceCredential.getId()});
}
}
// 验证ST是否正确 并且生成Assertion对象
final Assertion assertion = this.centralAuthenticationService.validateServiceTicket(serviceTicketId, service);
final ValidationSpecification validationSpecification = this.getCommandClass();
final ServletRequestDataBinder binder = new ServletRequestDataBinder(validationSpecification, "validationSpecification");
initBinder(request, binder);
binder.bind(request);
if (!validationSpecification.isSatisfiedBy(assertion)) {
logger.debug("Service ticket [{}] does not satisfy validation specification.", serviceTicketId);
return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_TICKET,
CasProtocolConstants.ERROR_CODE_INVALID_TICKET, null);
}
String proxyIou = null;
if (serviceCredential != null && this.proxyHandler.canHandle(serviceCredential)) {
proxyIou = this.proxyHandler.handle(serviceCredential, proxyGrantingTicketId);
if (StringUtils.isEmpty(proxyIou)) {
return generateErrorView(CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
CasProtocolConstants.ERROR_CODE_INVALID_PROXY_CALLBACK,
new Object[] {serviceCredential.getId()});
}
}
onSuccessfulValidation(serviceTicketId, assertion);
logger.debug("Successfully validated service ticket {} for service [{}]", serviceTicketId, service.getId());
return generateSuccessView(assertion, proxyIou, service, proxyGrantingTicketId);
} catch (final TicketValidationException e) {
final String code = e.getCode();
return generateErrorView(code, code,
new Object[] {serviceTicketId, e.getOriginalService().getId(), service.getId()});
} catch (final TicketException te) {
return generateErrorView(te.getCode(), te.getCode(),
new Object[] {serviceTicketId});
} catch (final UnauthorizedProxyingException e) {
return generateErrorView(e.getMessage(), e.getMessage(), new Object[] {service.getId()});
} catch (final UnauthorizedServiceException e) {
return generateErrorView(e.getMessage(), e.getMessage(), null);
}
}
查看ST验证的主要方法CentralAuthenticationServiceImpl类的validateServiceTicket方法
public Assertion validateServiceTicket(final String serviceTicketId, final Service service) throws TicketException {
final RegisteredService registeredService = this.servicesManager.findServiceBy(service);
verifyRegisteredServiceProperties(registeredService, service);
// 从缓存中查看ST是否存在
final ServiceTicket serviceTicket = this.ticketRegistry.getTicket(serviceTicketId, ServiceTicket.class);
if (serviceTicket == null) {
logger.info("Service ticket [{}] does not exist.", serviceTicketId);
throw new InvalidTicketException(serviceTicketId);
}
try {
synchronized (serviceTicket) {
// ST是否过期
if (serviceTicket.isExpired()) {
logger.info("ServiceTicket [{}] has expired.", serviceTicketId);
throw new InvalidTicketException(serviceTicketId);
}
// ST是否合法
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());
}
}
final TicketGrantingTicket root = serviceTicket.getGrantingTicket().getRoot();
final Authentication authentication = getAuthenticationSatisfiedByPolicy(
root, new ServiceContext(serviceTicket.getService(), registeredService));
final Principal principal = authentication.getPrincipal();
final AttributeReleasePolicy attributePolicy = registeredService.getAttributeReleasePolicy();
logger.debug("Attribute policy [{}] is associated with service [{}]", attributePolicy, registeredService);
@SuppressWarnings("unchecked")
final Map<String, Object> attributesToRelease = attributePolicy != null
? attributePolicy.getAttributes(principal) : Collections.EMPTY_MAP;
final String principalId = registeredService.getUsernameAttributeProvider().resolveUsername(principal, service);
final Principal modifiedPrincipal = this.principalFactory.createPrincipal(principalId, attributesToRelease);
final AuthenticationBuilder builder = DefaultAuthenticationBuilder.newInstance(authentication);
builder.setPrincipal(modifiedPrincipal);
return new ImmutableAssertion(
builder.build(),
serviceTicket.getGrantingTicket().getChainedAuthentications(),
serviceTicket.getService(),
serviceTicket.isFromNewLogin());
} finally {
if (serviceTicket.isExpired()) {
this.ticketRegistry.deleteTicket(serviceTicketId);
}
}
}
这里主要看下serviceTicket.isValidFor(service)方法
@Override
public boolean isValidFor(final Service serviceToValidate) {
updateState();
return serviceToValidate.matches(this.service);
}
该方法实际调用的是AbstractWebApplicationService类的matches方法
@Override
public boolean matches(final Service service) {
try {
final String thisUrl = URLDecoder.decode(this.id, "UTF-8");
final String serviceUrl = URLDecoder.decode(service.getId(), "UTF-8");
logger.trace("Decoded urls and comparing [{}] with [{}]", thisUrl, serviceUrl);
return thisUrl.equalsIgnoreCase(serviceUrl);
} catch (final Exception e) {
logger.error(e.getMessage(), e);
}
return false;
}
可以看到验证ST就是验证的登录请求保存的service的ID和验证请求的service的ID是否相等,相等就认为ST合法。
这里也可以修改验证逻辑,添加自己的验证逻辑,但前提是确保ST验证的合法性,安全性。
这里的Service到底是什么呢,其实Service就是cas server把收到的客户端请求封装之后产生的对象
具体配置文件是argumentExtractorsConfiguration.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:util="http://www.springframework.org/schema/util"
xmlns="http://www.springframework.org/schema/beans"
xmlns:p="http://www.springframework.org/schema/p"
xmlns:c="http://www.springframework.org/schema/c"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd">
<description>
Argument Extractors are what are used to translate HTTP requests into requests of the appropriate protocol (i.e.
CAS, SAML, SAML2,
OpenId, etc.). By default, only CAS is enabled.
</description>
<!--
<bean id="samlArgumentExtractor" class="org.jasig.cas.support.saml.web.support.SamlArgumentExtractor" />
-->
<bean id="casArgumentExtractor" class="org.jasig.cas.web.support.CasArgumentExtractor"/>
<util:list id="argumentExtractors">
<!-- <ref bean="samlArgumentExtractor" /> -->
<ref bean="casArgumentExtractor"/>
</util:list>
</beans>
CasArgumentExtractor类
public final class CasArgumentExtractor extends AbstractArgumentExtractor {
@Override
public WebApplicationService extractServiceInternal(final HttpServletRequest request) {
return SimpleWebApplicationServiceImpl.createServiceFrom(request);
}
}
可以看到Service接口的实例是SimpleWebApplicationServiceImpl类的对象
/**
* Creates the service from the request.
*
* @param request the request
* @return the simple web application service impl
*/
public static SimpleWebApplicationServiceImpl createServiceFrom(
final HttpServletRequest request) {
final String targetService = request.getParameter(CONST_PARAM_TARGET_SERVICE);
final String service = request.getParameter(CONST_PARAM_SERVICE);
final String serviceAttribute = (String) request.getAttribute(CONST_PARAM_SERVICE);
final String method = request.getParameter(CONST_PARAM_METHOD);
final String serviceToUse;
if (StringUtils.hasText(targetService)) {
serviceToUse = targetService;
} else if (StringUtils.hasText(service)) {
serviceToUse = service;
} else {
serviceToUse = serviceAttribute;
}
if (!StringUtils.hasText(serviceToUse)) {
return null;
}
final String id = cleanupUrl(serviceToUse);
final String artifactId = request.getParameter(CONST_PARAM_TICKET);
return new SimpleWebApplicationServiceImpl(id, serviceToUse,
artifactId, "POST".equals(method) ? Response.ResponseType.POST
: Response.ResponseType.REDIRECT);
}
这里可以看到service的封装过程,由此可见登录时传递给cas server的service参数和ST验证时传递给cas server的service参数必须相同,ST才能验证成功
服务端验证ST流程结束。