CAS 认证
概述
JA-SIG 开发了一种企业级的单点登录系统,称为 CAS。 与其他方案不同,JA-SIG 的中央认证服务(CAS)是开源的、被广泛使用、易于理解、平台无关,并且支持代理功能。 Spring Security 对 CAS 提供了全面支持,并提供了从单应用部署的 Spring Security 到由企业级 CAS 服务器保护的多应用部署的简便迁移路径。
您可以在 www.apereo.org 了解更多关于 CAS 的信息。 您还需要访问该网站以下载 CAS 服务器文件。
CAS 的工作原理
尽管 CAS 官方网站包含了详细描述 CAS 架构的文档,但我们在 Spring Security 的上下文中再次提供该总体概述。 Spring Security 3.x 支持 CAS 3。 撰写本文时,CAS 服务器的版本为 3.4。
在您的企业中的某个地方,您需要设置一个 CAS 服务器。 CAS 服务器只是一个标准的 WAR 文件,因此设置服务器并没有什么困难之处。 在 WAR 文件中,您可以自定义登录页面以及其他向用户显示的单点登录页面。
在部署 CAS 3.4 服务器时,您还需要在 CAS 自带的 AuthenticationHandler 文件中指定一个 deployerConfigContext.xml。
AuthenticationHandler 提供了一个简单的方法,用于判断给定的一组凭据(Credentials)是否有效,并返回布尔值。
您的 AuthenticationHandler 实现需要连接到某种后端认证存储库,例如 LDAP 服务器或数据库。
CAS 本身已内置了多种 AuthenticationHandler,可帮助您完成此项工作。
当您下载并部署该服务器的 war 文件时,默认配置为:只要用户输入的密码与其用户名相同,即可成功通过认证,这在测试时非常有用。
除了CAS服务器本身之外,其他关键参与者当然是部署在您企业各处的安全Web应用程序。 这些Web应用程序被称为“服务”。 服务分为三种类型: 验证服务ticket的服务、能够获取代理ticket的服务,以及验证代理ticket的服务。 验证代理ticket有所不同,因为必须验证代理链列表,而且代理ticket通常可以重复使用。
Spring Security 与 CAS 的交互序列
Web 浏览器、CAS 服务器与受 Spring Security 保护的服务之间的基本交互流程如下:
-
用户正在浏览该服务的公开页面。 未涉及 CAS 或 Spring Security。
-
用户最终请求了一个受保护的页面,或者该页面所使用的某个 Bean 是受保护的。 Spring Security 的
ExceptionTranslationFilter将检测到AccessDeniedException或AuthenticationException。 -
由于用户的
Authentication对象(或其缺失)引发了AuthenticationException,ExceptionTranslationFilter将调用已配置的AuthenticationEntryPoint。 如果使用 CAS,则该类为CasAuthenticationEntryPoint。 -
CasAuthenticationEntryPoint会将用户的浏览器重定向到 CAS 服务器。 它还会附带一个service参数,该参数是 Spring Security 服务(即您的应用程序)的回调 URL。 例如,浏览器被重定向到的 URL 可能是 my.company.com/cas/login?service=https%3A%2F%2Fserver3.company.com%2Fwebapp%2Flogin/cas。 -
在用户的浏览器重定向到 CAS 后,系统会提示用户输入其用户名和密码。 如果用户提供的会话 Cookie 表明他们之前已经登录过,则不会再次提示登录(此流程存在一个例外情况,我们稍后会进行说明)。 CAS 将使用上面讨论过的
PasswordHandler(如果使用 CAS 3.0,则为AuthenticationHandler)来判断用户名和密码是否有效。 -
登录成功后,CAS 会将用户的浏览器重定向回原始服务。 同时还会附带一个
ticket参数,该参数是一个不透明的字符串,代表“服务ticket”(service ticket)。 延续我们之前的示例,浏览器被重定向到的 URL 可能是 server3.company.com/webapp/login/cas?ticket=ST-0-ER94xMJmn6pha35CQRoZ。 -
回到服务端的 Web 应用程序,
CasAuthenticationFilter始终监听对/login/cas的请求(该路径是可配置的,但在本介绍中我们将使用默认值)。 该处理过滤器将构造一个代表服务ticket(service ticket)的UsernamePasswordAuthenticationToken。 其中,主体(principal)将等于CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER,而凭据(credentials)则是服务ticket的不透明值(opaque value)。 随后,该身份验证请求将被传递给已配置的AuthenticationManager。 -
AuthenticationManager的实现将是ProviderManager,而后者又配置了CasAuthenticationProvider。CasAuthenticationProvider仅响应包含 CAS 特定主体(例如UsernamePasswordAuthenticationToken)的CasAuthenticationFilter.CAS_STATEFUL_IDENTIFIER以及CasAuthenticationToken(稍后讨论)。 -
CasAuthenticationProvider将使用TicketValidator的实现来验证服务ticket(service ticket)。 通常会使用Cas20ServiceTicketValidator,这是 CAS 客户端库中包含的类之一。 如果应用程序需要验证代理ticket(proxy ticket),则应使用Cas20ProxyTicketValidator。TicketValidator会向 CAS 服务器发起 HTTPS 请求,以验证服务ticket。 该请求还可能包含一个代理回调 URL(proxy callback URL),如下例所示:my.company.com/cas/proxyValidate?service=https%3A%2F%2Fserver3.company.com%2Fwebapp%2Flogin/cas&ticket=ST-0-ER94xMJmn6pha35CQRoZ&pgtUrl=https://server3.company.com/webapp/login/cas/proxyreceptor。 -
回到 CAS 服务器,验证请求将被接收。 如果所提供的服务ticket与该ticket所颁发给的服务 URL 相匹配,CAS 将返回一个肯定的 XML 响应,其中包含用户名。 如果认证过程中涉及任何代理(如下文所述),代理列表也会包含在 XML 响应中。
-
[可选] 如果向 CAS 验证服务发起的请求中包含了代理回调 URL(通过
pgtUrl参数),CAS 将在 XML 响应中包含一个pgtIou字符串。 该pgtIou代表一个代理授予ticket IOU(Proxy-Granting Ticket IOU)。 随后,CAS 服务器将主动建立一条返回到pgtUrl的 HTTPS 连接。 此操作用于对 CAS 服务器与所声明的服务 URL 进行相互认证。 该 HTTPS 连接将用于向原始 Web 应用程序发送一个代理授予ticket。 例如,server3.company.com/webapp/login/cas/proxyreceptor?pgtIou=PGTIOU-0-R0zlgrl4pdAQwBvJWO3vnNpevwqStbSGcq3vKB2SqSFFRnjPHt&pgtId=PGT-1-si9YkkHLrtACBo64rmsi3v2nf7cpCResXg5MpESZFArbaZiOKH。 -
Cas20TicketValidator将解析从 CAS 服务器接收到的 XML。 它会向CasAuthenticationProvider返回一个TicketResponse,其中包含用户名(必填)、代理列表(如果涉及的话)以及代理授予ticket IOU(如果请求了代理回调)。 -
下一个
CasAuthenticationProvider将调用已配置的CasProxyDecider。CasProxyDecider指示TicketResponse中的代理列表是否可被服务接受。 Spring Security 提供了多种实现:RejectProxyTickets、AcceptAnyCasProxy和NamedCasProxyDecider。 这些名称大多一目了然,除了NamedCasProxyDecider,它允许提供一组受信任的代理List。 -
CasAuthenticationProvider接下来会请求一个AuthenticationUserDetailsService,以加载适用于GrantedAuthority中所包含用户的Assertion对象。 -
如果没有出现问题,
CasAuthenticationProvider将构造一个CasAuthenticationToken,其中包含TicketResponse中的详细信息,以及一组至少包含GrantedAuthority的FACTOR_BEARER。 -
然后控制权返回到
CasAuthenticationFilter,该过滤器会将创建的CasAuthenticationToken放入安全上下文中。 -
用户的浏览器将被重定向到最初引发
AuthenticationException的页面(或根据配置重定向到自定义目标页面)。
很高兴你还在! 现在让我们来看看这是如何配置的。
CAS 客户端配置
由于 Spring Security 的支持,CAS 的 Web 应用端变得非常简单。 我们假定您已经了解使用 Spring Security 的基础知识,因此下文将不再重复介绍这些内容。 我们将采用基于命名空间的配置方式,并根据需要添加 CAS 相关的 Bean。 每一节的内容都建立在前一节的基础之上。 完整的 CAS 示例应用程序可在 Spring Security 的示例中找到。
服务ticket认证
本节介绍如何配置 Spring Security 以对服务ticket(Service Tickets)进行身份验证。
通常,这正是 Web 应用程序所需要的全部功能。
您需要在应用程序上下文中添加一个 ServiceProperties Bean。
该 Bean 代表您的 CAS 服务:
<bean id="serviceProperties"
class="org.springframework.security.cas.ServiceProperties">
<property name="service"
value="https://localhost:8443/cas-sample/login/cas"/>
<property name="sendRenew" value="false"/>
</bean>
service 必须等于一个将被 CasAuthenticationFilter 监控的 URL。
sendRenew 默认为 false,但如果您的应用程序特别敏感,则应将其设置为 true。
此参数的作用是告知 CAS 登录服务:单点登录(SSO)方式的登录是不可接受的。
相反,用户必须重新输入其用户名和密码,才能获得对该服务的访问权限。
应配置以下 Bean 以启动 CAS 认证流程(假设您使用的是命名空间配置):
<security:http entry-point-ref="casEntryPoint">
...
<security:custom-filter position="CAS_FILTER" ref="casFilter" />
</security:http>
<bean id="casFilter"
class="org.springframework.security.cas.web.CasAuthenticationFilter">
<property name="authenticationManager" ref="authenticationManager"/>
</bean>
<bean id="casEntryPoint"
class="org.springframework.security.cas.web.CasAuthenticationEntryPoint">
<property name="loginUrl" value="https://localhost:9443/cas/login"/>
<property name="serviceProperties" ref="serviceProperties"/>
</bean>
要使 CAS 正常运行,ExceptionTranslationFilter 的 authenticationEntryPoint 属性必须设置为 CasAuthenticationEntryPoint Bean。
这可以像上面示例中那样,通过使用 entry-point-ref 轻松实现。
CasAuthenticationEntryPoint 必须引用 ServiceProperties Bean(如上所述),该 Bean 提供了企业 CAS 登录服务器的 URL。
用户的浏览器将被重定向到该地址。
CasAuthenticationFilter 的属性与 UsernamePasswordAuthenticationFilter(用于基于表单的登录)非常相似。
您可以使用这些属性来自定义认证成功和失败时的行为等。
接下来,您需要添加一个 CasAuthenticationProvider 及其协作组件:
<security:authentication-manager alias="authenticationManager">
<security:authentication-provider ref="casAuthenticationProvider" />
</security:authentication-manager>
<bean id="casAuthenticationProvider"
class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
<property name="authenticationUserDetailsService">
<bean class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
<constructor-arg ref="userService" />
</bean>
</property>
<property name="serviceProperties" ref="serviceProperties" />
<property name="ticketValidator">
<bean class="org.apereo.cas.client.validation.Cas20ServiceTicketValidator">
<constructor-arg index="0" value="https://localhost:9443/cas" />
</bean>
</property>
<property name="key" value="an_id_for_this_auth_provider_only"/>
</bean>
<security:user-service id="userService">
<!-- Password is prefixed with {noop} to indicate to DelegatingPasswordEncoder that
NoOpPasswordEncoder should be used.
This is not safe for production, but makes reading
in samples easier.
Normally passwords should be hashed using BCrypt -->
<security:user name="joe" password="{noop}joe" authorities="ROLE_USER" />
...
</security:user-service>
CasAuthenticationProvider 在用户通过 CAS 认证后,会使用一个 UserDetailsService 实例来加载该用户的权限。
此处我们展示了一个简单的基于内存的配置。
请注意,CasAuthenticationProvider 实际上并不使用密码进行认证,但它会使用权限信息。
如果你回顾CAS 工作原理一节,这些 bean 的含义都相当直观明了。
这完成了 CAS 最基本的配置。 如果你没有犯任何错误,你的 Web 应用程序应该能够顺利地在 CAS 单点登录框架内正常运行。 Spring Security 的其他部分无需关心认证是由 CAS 处理的这一事实。 在接下来的章节中,我们将讨论一些(可选的)更高级的配置。
单次注销
CAS 协议支持单点登出(Single Logout),可以轻松添加到您的 Spring Security 配置中。 以下是为处理单点登出而对 Spring Security 配置所做的更新。
<security:http entry-point-ref="casEntryPoint">
...
<security:logout logout-success-url="/cas-logout.jsp"/>
<security:custom-filter ref="requestSingleLogoutFilter" before="LOGOUT_FILTER"/>
<security:custom-filter ref="singleLogoutFilter" before="CAS_FILTER"/>
</security:http>
<!-- This filter handles a Single Logout Request from the CAS Server -->
<bean id="singleLogoutFilter" class="org.apereo.cas.client.session.SingleSignOutFilter"/>
<!-- This filter redirects to the CAS Server to signal Single Logout should be performed -->
<bean id="requestSingleLogoutFilter"
class="org.springframework.security.web.authentication.logout.LogoutFilter">
<constructor-arg value="https://localhost:9443/cas/logout"/>
<constructor-arg>
<bean class=
"org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler"/>
</constructor-arg>
<property name="filterProcessesUrl" value="/logout/cas"/>
</bean>
logout 元素会将用户从本地应用程序中登出,但不会结束与 CAS 服务器或其他已登录应用程序的会话。
requestSingleLogoutFilter 过滤器允许通过请求 /spring_security_cas_logout URL,将应用程序重定向到配置好的 CAS 服务器登出地址。
随后,CAS 服务器会向所有已登录的服务发送单点登出(Single Logout)请求。
singleLogoutFilter 通过在一个静态的 HttpSession 中查找对应的 Map 并使其失效,来处理该单点登出请求。
可能会让人感到困惑,为什么同时需要 logout 元素和 singleLogoutFilter。
最佳实践是先在本地执行注销操作,因为 SingleSignOutFilter 仅将 HttpSession 存储在一个静态的 Map 中,以便随后调用其 invalidate 方法。
使用上述配置,注销流程如下:
-
用户请求
/logout,这将使用户从本地应用程序中登出,并将用户重定向到注销成功页面。 -
注销成功页面
/cas-logout.jsp应提示用户点击指向/logout/cas的链接,以便从所有应用程序中注销。 -
当用户点击该链接时,用户将被重定向到 CAS 单点登出 URL(localhost:9443/cas/logout)。
-
在 CAS 服务器端,CAS 单点登出 URL 会向所有 CAS 服务提交单点登出请求。 在 CAS 服务端,Apereo 的
SingleSignOutFilter通过使原始会话失效来处理登出请求。
下一步是将以下内容添加到您的 web.xml 中
<filter>
<filter-name>characterEncodingFilter</filter-name>
<filter-class>
org.springframework.web.filter.CharacterEncodingFilter
</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>characterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<listener>
<listener-class>
org.apereo.cas.client.session.SingleSignOutHttpSessionListener
</listener-class>
</listener>
使用 SingleSignOutFilter 时,可能会遇到一些编码问题。
因此,建议添加 CharacterEncodingFilter,以确保在使用 SingleSignOutFilter 时字符编码正确。
有关详细信息,请参阅 Apereo CAS 的文档。
SingleSignOutHttpSessionListener 可确保当 HttpSession 过期时,用于单点登出的映射会被移除。
使用 CAS 向无状态服务进行身份验证
本节介绍如何使用 CAS 对服务进行身份验证。 换句话说,本节讨论如何设置一个客户端,该客户端使用通过 CAS 进行身份验证的服务。 下一节将介绍如何设置一个无状态服务以使用 CAS 进行身份验证。
配置 CAS 以获取代理授予ticket
为了向无状态服务进行身份验证,应用程序需要获取一个代理授予ticket(PGT)。 本节介绍如何在基于 cas-st[服务ticket认证] 配置的基础上,配置 Spring Security 以获取 PGT。
第一步是在您的 Spring Security 配置中包含一个 ProxyGrantingTicketStorage。
它用于存储由 CasAuthenticationFilter 获取的 PGT(代理授予ticket),以便后续用来获取代理ticket。
下面展示了一个示例配置。
<!--
NOTE: In a real application you should not use an in memory implementation.
You will also want to ensure to clean up expired tickets by calling
ProxyGrantingTicketStorage.cleanup()
-->
<bean id="pgtStorage" class="org.apereo.cas.client.proxy.ProxyGrantingTicketStorageImpl"/>
下一步是更新 CasAuthenticationProvider,使其能够获取代理ticket(proxy tickets)。
为此,请将 Cas20ServiceTicketValidator 替换为 Cas20ProxyTicketValidator。
应将 proxyCallbackUrl 设置为应用程序接收 PGT(Proxy Granting Ticket)的 URL。
最后,配置还应引用 ProxyGrantingTicketStorage,以便可以使用 PGT 来获取代理ticket。
您可以在下方找到应进行的配置更改示例。
<bean id="casAuthenticationProvider"
class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
...
<property name="ticketValidator">
<bean class="org.apereo.cas.client.validation.Cas20ProxyTicketValidator">
<constructor-arg value="https://localhost:9443/cas"/>
<property name="proxyCallbackUrl"
value="https://localhost:8443/cas-sample/login/cas/proxyreceptor"/>
<property name="proxyGrantingTicketStorage" ref="pgtStorage"/>
</bean>
</property>
</bean>
最后一步是更新 CasAuthenticationFilter,使其能够接收 PGT 并将其存储在 ProxyGrantingTicketStorage 中。
务必确保 proxyReceptorUrl 与 proxyCallbackUrl 的 Cas20ProxyTicketValidator 相匹配。
下面展示了一个示例配置。
<bean id="casFilter"
class="org.springframework.security.cas.web.CasAuthenticationFilter">
...
<property name="proxyGrantingTicketStorage" ref="pgtStorage"/>
<property name="proxyReceptorUrl" value="/login/cas/proxyreceptor"/>
</bean>
使用代理ticket调用无状态服务
现在 Spring Security 已经能够获取 PGT(代理授予ticket),你可以使用它们来创建代理ticket(Proxy Tickets),用于向无状态服务进行身份验证。
CAS 示例应用程序 中的 ProxyTicketSampleServlet 提供了一个可运行的示例。
下面可以找到示例代码:
-
Java
-
Kotlin
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// NOTE: The CasAuthenticationToken can also be obtained using
// SecurityContextHolder.getContext().getAuthentication()
final CasAuthenticationToken token = (CasAuthenticationToken) request.getUserPrincipal();
// proxyTicket could be reused to make calls to the CAS service even if the
// target url differs
final String proxyTicket = token.getAssertion().getPrincipal().getProxyTicketFor(targetUrl);
// Make a remote call using the proxy ticket
final String serviceUrl = targetUrl+"?ticket="+URLEncoder.encode(proxyTicket, "UTF-8");
String proxyResponse = CommonUtils.getResponseFromServer(serviceUrl, "UTF-8");
...
}
protected fun doGet(request: HttpServletRequest, response: HttpServletResponse?) {
// NOTE: The CasAuthenticationToken can also be obtained using
// SecurityContextHolder.getContext().getAuthentication()
val token = request.userPrincipal as CasAuthenticationToken
// proxyTicket could be reused to make calls to the CAS service even if the
// target url differs
val proxyTicket = token.assertion.principal.getProxyTicketFor(targetUrl)
// Make a remote call using the proxy ticket
val serviceUrl: String = targetUrl + "?ticket=" + URLEncoder.encode(proxyTicket, "UTF-8")
val proxyResponse = CommonUtils.getResponseFromServer(serviceUrl, "UTF-8")
}
代理ticket认证
CasAuthenticationProvider 会区分有状态客户端和无状态客户端。
有状态客户端是指向 filterProcessesUrl 的 CasAuthenticationFilter 提交请求的任何客户端。
无状态客户端是指在除 CasAuthenticationFilter 之外的其他 URL 上向 filterProcessesUrl 提交身份验证请求的任何客户端。
由于远程调用协议无法在 HttpSession 的上下文中表示自身,因此无法依赖默认做法——即在请求之间将安全上下文存储在会话中。
此外,由于 CAS 服务器在 TicketValidator 验证ticket后会使其失效,因此在后续请求中重复提交相同的代理ticket将无法生效。
一个显而易见的选择是完全不在远程协议客户端中使用 CAS。
然而,这样做会丧失 CAS 的许多理想特性。
作为一种折中方案,CasAuthenticationProvider 使用了一个 StatelessTicketCache。
该缓存仅用于无状态客户端,这些客户端使用的主体(principal)等于 CasAuthenticationFilter.CAS_STATELESS_IDENTIFIER。
具体过程是:CasAuthenticationProvider 会将生成的 CasAuthenticationToken 存储在 StatelessTicketCache 中,并以代理ticket(proxy ticket)作为键。
因此,远程协议客户端可以出示相同的代理ticket,而 CasAuthenticationProvider 无需再次联系 CAS 服务器进行验证(首次请求除外)。
一旦通过认证,该代理ticket即可用于除原始目标服务之外的其他 URL。
本节在前几节的基础上进行扩展,以支持代理ticket(proxy ticket)认证。 第一步是如下面所示,指定对所有工件(artifacts)进行认证。
<bean id="serviceProperties"
class="org.springframework.security.cas.ServiceProperties">
...
<property name="authenticateAllArtifacts" value="true"/>
</bean>
下一步是为 CasAuthenticationFilter 指定 serviceProperties 和 authenticationDetailsSource。
serviceProperties 属性指示 CasAuthenticationFilter 尝试对所有工件进行身份验证,而不仅仅是 filterProcessesUrl 上存在的工件。
ServiceAuthenticationDetailsSource 会创建一个 ServiceAuthenticationDetails,确保基于 HttpServletRequest 的当前 URL 在验证ticket时被用作服务 URL。
生成服务 URL 的方法可以通过注入自定义的 AuthenticationDetailsSource 来定制,该对象返回自定义的 ServiceAuthenticationDetails。
<bean id="casFilter"
class="org.springframework.security.cas.web.CasAuthenticationFilter">
...
<property name="serviceProperties" ref="serviceProperties"/>
<property name="authenticationDetailsSource">
<bean class=
"org.springframework.security.cas.web.authentication.ServiceAuthenticationDetailsSource">
<constructor-arg ref="serviceProperties"/>
</bean>
</property>
</bean>
您还需要更新CasAuthenticationProvider以处理代理ticket。
为此,请将Cas20ServiceTicketValidator替换为Cas20ProxyTicketValidator。
您需要配置statelessTicketCache以及您希望接受的代理。
下面提供了一个示例,展示了为接受所有代理所需进行的更新。
<bean id="casAuthenticationProvider"
class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
...
<property name="ticketValidator">
<bean class="org.apereo.cas.client.validation.Cas20ProxyTicketValidator">
<constructor-arg value="https://localhost:9443/cas"/>
<property name="acceptAnyProxy" value="true"/>
</bean>
</property>
<property name="statelessTicketCache">
<bean class="org.springframework.security.cas.authentication.SpringCacheBasedTicketCache">
<property name="cache">
<bean class="net.sf.ehcache.Cache"
init-method="initialise" destroy-method="dispose">
<constructor-arg value="casTickets"/>
<constructor-arg value="50"/>
<constructor-arg value="true"/>
<constructor-arg value="false"/>
<constructor-arg value="3600"/>
<constructor-arg value="900"/>
</bean>
</property>
</bean>
</property>
</bean>