【原创申明:文章为原创,欢迎非盈利性转载,但转载必须注明来源】前端
以前写过一篇文章,介绍单点登陆的基本原理。这篇文章重点介绍开源单点登陆系统CAS的登陆和注销的实现方法。并结合实际工做中碰到的问题,探讨在集群环境中应用单点登陆可能会面临的问题。这篇文章在上一篇的基础上,增长了第四部分,最终的解决方案。nginx
为了描述方便,假设有以下一个单点登陆系统。一套CASServer,两套CAS Client系统。为了描述的方便,省略CAS Server调用用户系统完成登陆,以及CASClient从用户系统读取用户详细信息的过程。web
假定有两个CAS Client应用,一个CAS Server。应用的部署,可能在不一样的服务器,也可能有不一样的访问IP或域名,即便是同一个浏览器,在各个应用中的Session信息也是不相同的。redis
浏览器中,每一个应用有一个独立的JSESSIONIDCookie。某一个应用,不可能读取到浏览器在其余应用中的Cookie信息。spring
假定用户首先访问CAS Client 01,系统提醒用户进行一次登陆;而后用户访问CAS Client2,不会再提示登陆而是直接登陆成功。mongodb
用户打开浏览器后第一次访问,重定向到单点登陆后,会提示用户输入帐号密码登陆。登陆成功以后,再跳转回CAS Client。后端
当用户浏览器已经登陆系统,切换到另外一个CASClient时,跟第一次访问有所不一样,由于已经登陆成功,就不会再提醒输入帐号密码登陆了。浏览器
当用户已经访问过CAS Client后,当用户再次访问,系统不会再跳转到CAS Server作认证。缓存
为了实现前述的单点登陆过程,以Java WEB项目为例,须要在 web.xml 中进行相应的配置。(为了排版,没有填写Filter的完整class名,请自行查阅补充。)安全
<filter>
<filter-name>CAS AuthenticationFilter</filter-name>
<filter-class>*.AuthenticationFilter</filter-class>
</filter>
<filter>
<filter-name>CAS Validation Filter</filter-name>
<filter-class>*.Cas10TicketValidationFilter</filter-class>
</filter>
<filter>
<filter-name>CAS HttpServletRequest WrapperFilter</filter-name>
<filter-class>*.HttpServletRequestWrapperFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS Validation Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>CAS AuthenticationFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>CAS HttpServletRequest WrapperFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
仔细看一下配置过滤器能够发现,三个过滤器正好对应流程图中三次访问CAS Client。
Authentication Filter:负责将未登陆用户跳转到登陆界面
Authentication Filter:负责验证Service Ticket
HttpServletRequest WrapperFilter:负责将用户信息封装到request和session中。
当用户访问系统后从系统注销,如何可以从每一个应用中都注销?注意前面1.4部分的描述,若是用户注销时,并无注销CASClient 02中的会话信息,若是用户在浏览器中直接访问这个应用,由于Session存在,并不会提醒用户从新登陆。
这会带来两个潜在的隐患:
一、 用户注销user1后换帐号user2从新登陆,进入CAS Client 02以后,当前身份其实仍是user1,并无如用户预期同样使用user2身份。
二、 用户user1点击注销后离开,没有关闭浏览器。这时候其余用户直接打开CAS Client 02,可以直接盗用user1的身份进行操做。
CAS已经考虑到统一注销的问题。
这里有三个重要的概念TGT、ST和Service,须要着重介绍一下,由于它们同后续统一注销的方案息息相关。
这是用户第一次访问CAS Client的URL。假设一个CAS Client应用部署在域名oa.company.com,使用HTTP协议,应用首页是index.htm。当用户第一次访问这个应用时,对应的URL地址是 http://oa.company.com/index.htm 。这个URL,对CAS Server来讲,就是一个service。
当用户第一次跳转到CAS Server的时候,能够看到传了一个参数service,就是这个值。当CASServer生成Ticket重定向到CAS Client的时候,实际就是在这个service 中添加了一个参数 ticket 。
TGT是CAS Server为每个登陆用户建立的登陆令牌。在CASServer上拥有了TGT,用户就能够证实本身在CASServer成功登陆过。TGT封装了SessionCookie值以及此Cookie值对应的用户信息。当HTTP请求到来时,CAS以此Cookie值为key查询缓存中有无TGT ,若是有的话,则相信用户已登陆过。
ST是CAS Server为用户签发的访问某一service的认证令牌。用户访问service时,service发现用户没有ST,浏览器会跳转到CASServer去获取ST。CAS Server发现用户有TGT,则签发一个ST,返回给用户。用户使用ST做为ticket参数去访问service,service拿ST去CAS Server验证,验证经过后,获得当前登陆用户的登陆名。
注意TGT和ST,是一对多的关系。一个TGT会维护一个 services 列表,每当为用户建立一个ST并认证经过后,会将这个ST添加到TGT的services列表中。这样,在CASServer端,这个services列表实际维护了一个用户登陆过的全部CASClient。这就为实现统一注销打下了基础。
CAS Client,为了实现统一注销,除了第一张介绍的三个登陆过程的过滤器以外,还须要添加一个统一注销过滤器。
<filter>
<filter-name>CAS Single Sign OutFilter</filter-name>
<filter-class>*.SingleSignOutFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CAS Single Sign OutFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<listener>
<listener-class>*.SingleSignOutHttpSessionListener</listener-class>
</listener>
用户在浏览器中点击“注销”连接,实际浏览器会访问CASServer的注销页面。收到注销请求后,CAS Server会读取到TGT,并检查当前用户登陆过的全部service,并依次发送注销请求。
CAS Client的注销,核心代码是SingleSignOutFilter,它的关键代码
public voiddoFilter(servletRequest, servletResponse, filterChain){
HttpServletRequest request =(HttpServletRequest)servletRequest;
if (handler.isTokenRequest(request)) {
handler.recordSession(request);
} else if (handler.isLogoutRequest(request)) {
handler.destroySession(request);
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
其中handler是SingleSignOutHandler的实例,这个对象完成用户在CASClient端登陆信息的维护和注销工做。
至此,CAS完整的登陆和注销过程就完成。
统一注销的实现,须要CAS Server经过HttpClient访问CAS Client的service。若是这个访问过程失败,就会致使统一注销失败。列了几种状况,不详述。
一、开发调试阶段,使用localhost访问CAS Client。
二、CAS Server部署在外网,CAS Client部署在内网。
三、网络安全设置,不容许CASServer访问CAS Client。
前面的论述,一直假定全部的CAS Client都是单点部署,没有集群。若是集群,会有什么影响,应该如何来解决?
假设使用nginx作集群前端,后面部署两台CAS Client 01的实例。咱们看看对登陆过程会有什么影响。
为了描述方便,CAS Client登陆过程会有三次请求(对应三个过滤器),咱们依次命名为Authentication Request / Validation Request / Wrapper Request。
Nginx缺省的分发规则,并非sticky模式,同一个浏览器的请求,会按照nginx自身某种规则进行分发。咱们曾经测试过,在双点集群环境下,Authentication Request和ValidationRequest会刚好被分发到两台服务器,这就会致使登陆过程死循环。
出现登陆死循环的缘由,主要在于nginx分发时,没有使用sticky策略,也就是同一个浏览器的请求,永远分发给同一台CAS Client实例。缺省nginx的分发策略,能够根据用户IP分发,实现的是同一个IP永远分发到同一台Client,这样就能解决死循环的问题。
当nginx实现了sitcky转发,同一个浏览器的访问会分发到同一个Client1实例,该用户的会话信息也一直保存在Client1实例中。
当用户统一注销时,由CAS Server向Client发送注销请求,这时候nginx没法确保按当前用户进行分发,所以可能会被分发到Client2。这时候,实际效果是注销失败。
这个问题,在咱们当前的环境中真实存在,尚未合理的解决方法。初步分析,大概有几个修改方向。
问题存在的缘由,是由于nginx在分发注销策略时,不能准确分发。若是能在这个环节进行修改,系统代码和环境,基本不用作任何修改。
这里有两种分发方法:
l CAS Server发送的注销请求,分发给对应的后台服务器。
l CAS Server发送的注销请求,广播到全部的后台服务器。
初步结论:同架构组进行了沟通,这两种方案都很难实现,特别是广播的方案,没在网络上找到相似成功的案例。
若是能实现集群Session的同步:同步建立、同步注销,主要在一个Client上实现了注销,其余Client也就同步注销。
这个会对Tomcat性能有影响。
即便是多个节点,它们的会话信息只有一份。一旦失效,则全部节点都失效。这只是一个设想,没有作技术调研,不知可以实现。
这有两种修改方法:
l 修改Tomcat的配置文件,使用redis保存Tomcat的会话信息。
l 修改代码而不是Tomcat,使用redis保存会话信息。
初步结论:架构组不容许修改生产环境的Tomcat,否认了第一种方法。咱们只能尝试修改代码并利用redis保存会话。
首先,在CAS Server中实现一个接口,用于判断某一个ST对应的TGT是否还有效。
在SingleSignOutFilter中,每次访问都调用CAS Server的这个新接口,判断用户是否已经注销。若是已经注销,则马上注销本实例中的会话信息。
这个方法是比较安全的解决办法,但每次请求都会调用CASServer接口,会对性能形成巨大影响。彻底不建议用这种方案。
对前面提到的几种方案作了初步调研以后:
l 技术实现困难,否认了方案1
l 性能考虑以及架构组的策略,否认方案2
l 架构组的策略,否认方案3中的第一种作法。
l 性能考虑,否认方案4。
所以,可能的作法是修改代码,使用redis保存会话信息。
四 使用redis保存会话
在目前的生产环境的限制下,咱们只能采用修改代码来实现redis保存会话的实现方案。
在Tomcat缺省的实现中,Session信息都是保存在JVM中,因此不能跨JVM共享。
要想将全部的session都保存到redis中,一种能想到的简单办法是本身写一个CustomSession,将会话信息保存到这个自定义的Session中,而且利用redis等进行保存。但这样作,会带来很大的代码改动,全部涉及到session读写操做的地方可能都须要修改。
咱们但愿找到更优雅的解决方案,可以修改更少的代码。
Request 和Session何时建立?如何传递?
Filter的调用入口函数是doFilter,传入的主要参数是request和response。在此以前,Tomcat已经建立好request。一般状况下,业务代码不须要关心request和session等对象如何建立的问题,只须要使用便可。每一个过滤器的实现,当须要继续流程的时候,只须要将获得的request和response传递给下一个filter就行。
但这仅仅是缺省作法,并不表示咱们不能修改或重写一个request对象。咱们想修改Session的保存位置,若是能在全部的Filter以前插入一个自定义过滤器,定义一个新的Request传递给后面的Filter,而且让后面的Filter和Servlet感觉不到变化,就能够实现这个目标。
在全部的Filter以前,插入一个新的Filter。
HttpServletRequest能够重写吗?
在Session重写一个RedisSessionRequest,继承自HttpServletRequestWrapper,并包含原request(RequestFacade)的引用。但须要读取Form参数时,直接调用oriRequest取值。当须要拿到Session对象进行会话信息访问时,调用重载后的函数。
这样就实现了request的封装,在后续的filter和servlet中经过request获取到的session,都是放在redis中的会话数据,再也不是缺省保存在JVM中的数据。
当nginx将同一个浏览器的请求分发给不一样的Tomcat时,都会根据SessionId从redis中读取Session。由于同一个浏览器发送请求的SessionID相同,因此在不一样的Tomcat实例中,会读取到同一个Session对象。
根据前面的分析,在项目中自定义Request,就能够实现需求。Spring Session已是一个成熟的开源实现,而且后端实现了将会话保存在redis、mongodb、jdbc等多种实现,咱们不必本身发明轮子。
Spring提供的例子代码很简洁,跟咱们已经实现的业务系统稍微有点不一样。在现有系统中,已经定义了bean jedisConnectionFactory,能够直接使用。
在pom.xml文件中,添加代码
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.2.0.RELEASE</version>
</dependency>
在项目中已经有redis配置文件spring-redis.xml,在其中添加内容
<context:annotation-config/>
<beans:beanclass="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"/>
在全部的过滤器前面添加一个新的过滤器
<filter>
<filter-name>springSessionRepositoryFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSessionRepositoryFilter</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>ERROR</dispatcher>
</filter-mapping>
集成Spring Session后,通过初步测试,可以达到预想效果。(感谢同事瑞钊的实际测试并提供截图)
用户登陆后查看redis中的数据,能够看到这些Session信息。
用户登陆后继续访问系统,不会切换到CAS登陆页面。
若是手工删掉redis中的session,从新访问,能够看到须要从新作一个CAS认证的过程。
后续须要部署一套生产环境的集群环境,验证统一注销的效果。通过前面两步测试验证,理论上说注销已经不是问题。