双网隔离环境下CAS单点登录的解决方案
在单位内架设的Web系统,如果使用CAS作为单点登录方案,往往会遇到从单位的不同网络(例如双网隔离下的外网和内网)访问时,系统无法正常登录使用的问题。基于本人实践,本文介绍一些解决方案。
技术背景
对CAS很熟悉的朋友可以跳过本章。
用Java开发一个支持单点登录SSO的Web应用,一般都需要部署两个服务:CAS服务和Web应用服务。CAS的相关资料网上很多,例如:https://www.jianshu.com/p/d443cfc10646。这里只简单叙述一下其工作方式,为解决方案做铺垫。
1、开发和部署CAS服务和Web应用服务。假设:
CAS服务地址:http://192.168.1.68:8585/cas
Web1应用地址:http://192.168.1.50:8083/poweroa tomcat
Web2应用地址:http://192.168.1.55:9010/ resin --没有统一后缀(login、wui及其他)
2、在Web应用服务的配置文件中配置CAS服务器的地址。
3、用户通过浏览器打开Web应用地址http://192.168.1.50:8083/poweroa时,Web应用发现用户未登录过(没有session和ticket),于是从配置文件中读取到CAS服务地址,然后把用户当前请求地址附加在这个地址后面,告诉用户的浏览器去跳转到这个地址,形如 http://192.168.1.68:8585/cas/login?service=http://192.168.1.50:8083/poweroa。
4、浏览器被重定向到上述CAS服务地址后,出现CAS的登录页面,用户输入账号登录成功后,CAS服务端从浏览器请求地址中提取出用户原来要访问的http://192.168.1.50:8083/poweroa这个地址,附加上ticket后让浏览器重定向到该地址。
5、浏览器被重定向到Web应用地址:http://192.168.1.50:8083/poweroa,Web应用服务端收到用户请求,并从请求中获取到sessionid、ticket等信息,然后在后台去访问http://192.168.1.68:8585/cas,验证ticket通过后即响应浏览器请求,通知浏览器重定向到Web应用首页。整个登录过程结束。
问题描述:
当系统只在一个独立的网络中运行时,CAS登录过程没有任何问题。但如果企事业单位采用了网络隔离模式,典型地,把网络分成内网和外网两部分,服务器部署在内网,通过端口映射的方式把服务端口向外网开放,外网用户只能通过外网IP访问单位网络。此时外网用户将无法完成单点登录过程。原因如下:
假设单位的外网地址是113.120.95.60,并且已经成功地做了如下端口映射:
CAS服务外网映射:113.120.95.60:81 -> 192.168.1.68:8585
Web服务外网映射:113.120.95.50:80 -> 192.168.1.68:8083
当一个外网用户输入 http://113.120.95.50:80/poweroa时,端口映射能够成功将请求转给内网的http://192.168.1.50:8083/poweroa,而Web应用的配置文件中登记的CAS服务地址是内网地址http://192.168.1.68:8585/cas,因此Web应用会通知浏览器重定向该地址。然而浏览器是处在外网网络中,当然无法连上该内网地址,于是浏览器报错,登录过程中断。作为用户,会看到浏览器地址栏显示一个内网URL。
问题分析
出现登录问题的本质原因是:
CAS登录过程依赖对CAS服务地址的重定向,而这个地址是在部署系统时固定配置好的,唯一的。如果内外网不能同时访问该地址,则必定无法登录成功。 上例中,外网用户无法访问http://192.168.1.2:8080/sso这个地址。
要解决该问题,思路就应该是:用什么方法能够使得***在重定向跳转过程中,始终都使用当前用户可以访问***的地址。
解决方案
解决方案1:通过统一域名和DNS解决
这是最简单优雅的解决方案。
简单说来,上面的例子中因为使用了IP地址作为服务地址,从而造成内外网无法同时访问的结果,那么把IP地址改成域名,通过内外网不同的DNS域名服务器设置,让无论内网还是外网都能通过域名访问到真正的服务地址,此问题就迎刃而解。具体做法如下:
1、给SSO服务和Web服务申请域名:
SSO服务域名:sso.mycompany.net
OA的Web应用域名:web.mycompany.net
2、在Web应用服务的配置文件中,配置CAS服务的地址为:
http://sso.mycompany.net:8080/cas。
3、DNS配置:
- 在内网的DNS服务器上,把这两个域名分别映射到192.168.1.68和192.168.1.50;
- 在外网申请两个IP地址
IP1
和IP2
(满足特定前提下可以只用一个,参见后文),并分别在外网的DNS服务器分别绑定两个域名到这两个IP地址;
4、端口映射:分别把IP1
和IP2
的8080端口映射到内网的192.168.1.68和192.168.1.50的8080端口;
5、告诉内外网所有用户,应用的访问地址是http://web.mycompany.net:8080/web
上述工作完成后,无论内网用户还是外网用户,当访问http://web.mycompany.net:8080/web时,浏览器都会被重定向到http://sso.mycompany.net:8080/cas来进行单点登录,而这个地址都会根据所在网络不同,解析并导向到最终的内网服务器上,从而实现成功登录。
该方案的一个小小缺陷是因为地址必须相同,所以内外网必须使用相同的服务端口号,即如果内网服务使用了8080,外网也必须使用8080。上述的例子中,因为cas和web都使用了8080端口,因此外网不得不用两个外网IP地址来分别映射。如果cas和web的端口不同,则可以只使用一个外网IP地址的两个端口来分别映射,此时外网DNS把两个域名映射到同一个IP地址上即可。
遗憾的是,现实中很多单位的IT部门要么难以搞定域名申请和配置,要么因为懒而直接甩锅给业务系统开发商,要求从应用层去解决,总之因为各种客观限制,这个最优雅最简洁的方案却是最难推动的
解决方案2:通过应用程序端解决
首先说明,这是***绝对不建议***采用的方案。这里仅仅作为一种可能性简要介绍一下思路。
回顾上述的失败流程,因为Web应用对外网用户给出了错误的内网CAS服务地址,从而造成外网用户无法登录,那么理论上如果Web应用能够识别出外网用户,并让其重定向到正确的外网CAS地址,则有可能解决此问题。具体来说,需要Web应用通过用户的HTTP请求中的信息判断用户来自哪里(例如是否来自外网与内网之间的网关),区分内网和外网给出不同的重定向地址,并解决后续从CAS跳转回Web应用地址、网页中的超链接地址动态切换等一系列问题。
此方法过于麻烦,配置复杂,需要对系统源码动手,如果CAS后面对接了多个系统(之所以要上SSO,肯定是应用系统数量比较多对不对?),这工作量……更别说如果这些系统的源码不在你手上或根本就是别的公司开发的,涉及到复杂的协调问题,那就更难进行了。故该方案在此不再展开,后面有参考链接,可直接看。本人之所以提这个方案,是因为公司里曾经有程序猿试图采用这种方式解决,结果遇到刚才说的工作量、第三方协调等问题,搞不下去了……
下面几篇文章都是该方案的思路,供参考:
解决方案3(优先考虑):通过Apache2.4/Nginx反向代理
本方案的核心思想是,在内网架设一台Apache/Nginx服务器,通过端口映射向外网用户提供系统访问入口,利用Apache的反向代理能力,把内网服务器生成的重定向内网地址,转换成外网地址,再传给外网用户浏览器。整个方案不需要对CAS和应用做任何配置或源码上的修改! 简单的示意图如下:
配置1:定义VirtualHost,开启反向代理
在Apache的配置文件中,定义VirtualHost,监听8085端口,并指定ServerName 113.120.95.60,意思就是该VirtualHost中的配置仅当接收到的请求URL中的主机名是113.120.95.60时才起作用。其他几个配置就不一一说明了。
# 外网地址 <VirtualHost *:8085> ErrorLog "logs/error.8085.out.log" ServerName 113.120.95.60 # 关闭正向代理 ProxyRequests Off # 反向代理时不保留原始Request中的HOST(即代理服务器自身Host) ProxyPreserveHost Off <Proxy *>
Require all granted
</Proxy> </VirtualHost>
配置2:反向代理
在VirtualHost中,通过ProxyPass
和ProxyPassReverse
两个指令,实现URL反向代理:
# SSO反向代理
ProxyPass /cas http://192.168.1.68:8585/cas
ProxyPassReverse /cas http://192.168.1.68:8585/cas
# Web应用反向代理
ProxyPass /poweroa http://192.168.1.50:8083/poweroa
ProxyPassReverse /poweroa http://192.168.1.50:8083/poweroa
ProxyPass
指示Apache接收到某个路径请求后,需要把请求转发给哪一个地址。例如:
ProxyPass /poweroa http://192.168.1.50:8083/poweroa
这句话的意思是,当Apache收到的请求路径是/poweroa开头时,需要把请求原封不动地转发给http://192.168.1.50:8083/poweroa(/sso后面URL的其他部分也会原样附加上去),然后把http://192.168.1.50:8083/poweroa返回的Response,再原封不动地返回给正在访问Apache的浏览器。这个过程对浏览器是透明的,实现了用户输入外网地址就能看见内网系统页面的效果。
不过,ProxyPass无法处理重定向。当http://192.168.1.50:8083/poweroa的响应报文的HTTP头中带有Location重定向标识时,浏览器会不折不扣按此标识跳转。在本文开始的例子中就说过,Web应用返回给访问者的重定向地址是内网地址。在通过Apache返回时,必须把这个内网地址改成外网地址,这就需要用到ProxyPassReverse指令。例如:
ProxyPassReverse /cas http://192.168.1.68:8585/cas
这句话的意思是,如果Apache接收到的服务器响应中重定向标识Location是http://192.168.1.68:8585/cas,则将其替换为当前用户访问地址后加上/cas。这样,web应用本来是要求浏览器重定向到http://192.168.1.68:8585/cas/login?xxxxx,但这个地址在经过Apache返回时被Apache篡改成了http://113.120.95.60:8085/cas/login?xxxxx,浏览器并不知道这一切幕后工作,只是单纯按照接收到的信息进行重定向,去请求http://113.120.95.60:8085/cas/login?xxxxx。该请求再次经过Apache并被ProxyPass指令转换成真正的内网CAS服务器地址,从而能够正确到达CAS服务器。后面过程就不详述了。
总之,通过这两个指令就基本实现了在不对应用系统做任何配置修改的情况下的外网访问。
值得一提的是,上面的例子中CAS服务和Web应用的URL都是带有子路径/cas和/poweroa的。这里有两个最佳实践:
- 在做反向代理时,最好让系统运行在某个子路径下,而不要运行在根路径下;
- Apache/Nginx上的反向代理配置,最好配置与后方系统相同的子路径做为映射路径;
按上述最佳实践来做,能够省掉很多麻烦事。
如果系统运行在URL根目录下,即http://192.168.1.50:8083,而不是http://192.168.1.50:8083/poweroa,在做反向代理配置时就需要把路径映射到根路径,如果有多个后方系统都使用根目录,反向代理将无法区分。有人一定会说使用ServerName、多IP地址、多端口等方式可以实现根据不同请求来源而区分处理,但现实是我遇到的大多数单位提供的对外访问接口,既没有域名,也没有多个IP,甚至也不会给你开多个端口,这种情况下,只能靠子路径来让反向代理知道应该到哪里去。那位说了,可是已经部署好的系统就是在根目录的,我怎么办?好吧,你施展人格魅力的时候到了,想办法去说服他们改变吧!如果魅力不够,也许你可以在内网再架设一个反向代理做二次映射来专门解决这个特定应用的路径问题,我也没试过行不行,祝你成功,并且成功后告诉我一声:
如果映射的子路径和系统真实的子路径不同行不行呢?比如说CAS服务地址是http://192.168.1.68:8585/cas,我在Apache上配置用/cas路径来映射,即对外的地址为http:// 113.120.95.60:8085/cas,有什么问题?答案是,不一定,但有可能会遇到问题。这个问题通常出在一些系统在开发或部署时,有可能把这个子路径作为系统变量的一部分,在页面超链接等地方直接使用/cas,因为Apache无法识别和处理这个地址,所以页面也就无法正常显示。总而言之,不要自找麻烦,简单点,生活尽量简单点。
配置3:页面内容替换
完成上述反向代理配置后,其实大多数系统就已经能够正常使用了。不过偶尔还会遇到一些系统在页面上直接超链接其他系统的,比如一个门户系统在页面上超链接其他业务系统的地址是再正常不过的了。超链接只能写一个URL,要么是内网的,要么是外网的,怎么同时满足内网用户和外网用户呢?前面的反向代理配置,不会处理页面中的URL,此时就需要用到另一个大杀器:mod-substitute。
Apache的mod-substitute模块可以通过正则表达式,实时替换网页中的内容。具体语法参考请移步原厂:mod-substitute。下面是配置实例:
##去掉GIZP标识,否则无法替换页面内容
LoadModule headers_module modules/mod_headers.so
RequestHeader unset Accept-Encoding
## 加载替换模块,过滤指定类型页面
LoadModule substitute_module modules/mod_substitute.so
AddOutputFilterByType SUBSTITUTE text/html
AddOutputFilterByType SUBSTITUTE text/plain
AddOutputFilterByType SUBSTITUTE application/json
AddOutputFilterByType SUBSTITUTE application/x-javascript
AddOutputFilterByType SUBSTITUTE application/javascript
AddOutputFilterByType SUBSTITUTE text/javascript
## 把服务器响应中的内网IP地址改成外网地址
Substitute "s|192.168.1.50:8083/poweroa|113.120.95.60:8085/poweroa|n"
Substitute "s|192.168.1.68:8585/cas|113.120.95.60:8085/cas|n"
Substitute "s|192.168.1.55:9010/wui|113.120.95.60:8085/wui|n"
Substitute "s|192.168.1.55:9010/login|113.120.95.60:8085/login|n"
上面的配置中,首先重置Accept-Encoding,这实际上就会去掉浏览器发出的请求头中支持gzip的申明,于是服务器就不会返回经过压缩的页面内容,这样才能够进行文本替换。AddOutputFilterByType指明要进行替换的Content Type种类,显然这里应该根据系统的实际情况,列出所有有可能出现系统URL的地方,越少越好,像图片之类的当然就不需要了,因为这玩意肯定影响性能。Substitute进行实际的内容替换,注意前面的地址是被替换内容,后面的是替换内容,别写反了啊!
通过上面的配置后,从外网尝试打开原来有错误链接的页面,看看链接地址是不是被改变了?当然你可以拿页面中的任何内容测试一下,比如把版权信息改成“圣诞节快乐”,把你情敌的姓名改成公司领导姓名,看看用户反应如何?我就是说说而已,被投诉了可别找我啊!现在知道为什么我说这个东西是大杀器了吧,随意篡改网页内容太可怕了有木有!网络世界真的不能没有SSL、HTTPS!如果系统本身已经使用HTTPS方式部署,这方法可能会失效,参见后面的专项讨论。
最后强调一下,这个配置会降低性能,对HTTPS可能会无效,仅当网页内容中出现了系统URL绝对路径且需要动态替换时才考虑使用!
一个完整的配置文件
LoadModule proxy_module modules/mod_proxy.so LoadModule proxy_http_module modules/mod_proxy_http.so LoadModule substitute_module modules/mod_substitute.so LoadModule headers_module modules/mod_headers.so NameVirtualHost *:8085 # 外网地址 <VirtualHost *:8085> ErrorLog "logs/error.8085.out.log" ServerName 113.120.95.60 # 关闭正向代理 ProxyRequests Off # 反向代理时不保留原始Request中的HOST(即代理服务器自身Host) ProxyPreserveHost Off <Proxy *> Require all granted </Proxy> ##去掉GIZP标识,否则无法替换页面内容 RequestHeader unset Accept-Encoding ## 过滤指定类型页面 AddOutputFilterByType SUBSTITUTE text/html AddOutputFilterByType SUBSTITUTE text/plain AddOutputFilterByType SUBSTITUTE application/json AddOutputFilterByType SUBSTITUTE application/x-javascript AddOutputFilterByType SUBSTITUTE application/javascript AddOutputFilterByType SUBSTITUTE text/javascript ## 把服务器响应中的内网IP地址改成外网地址 Substitute "s|192.168.1.50:8083/poweroa|113.120.95.60:8085/poweroa|n"
Substitute "s|192.168.1.68:8585/cas|113.120.95.60:8085/cas|n"
Substitute "s|192.168.1.55:9010/wui|113.120.95.60:8085/wui|n"
Substitute "s|192.168.1.55:9010/login|113.120.95.60:8085/login|n"
ProxyPass /cas http://192.168.1.68:8585/cas
ProxyPassReverse /cas http://192.168.1.68:8585/cas
ProxyPass /poweroa http://192.168.1.50:8083/poweroa
ProxyPassReverse /poweroa http://192.168.1.50:8083/poweroa
#oa登录反向代理
ProxyPass /login http://192.168.1.55:9010/login
ProxyPassReverse /login http://192.168.1.55:9010/login
#oa主界面反向代理
ProxyPass /wui http://192.168.1.55:9010/wui
ProxyPassReverse /wui http://192.168.1.55:9010/wui
#oa其他部分反向代理
ProxyPass / http://192.168.1.55:9010/
ProxyPassReverse / http://192.168.1.55:9010/
</VirtualHost>
调试技巧
在调试Apache2.4/Nginx的反向代理配置时,需要熟练掌握一些方法:
- 启用浏览器的调试功能(按下F12键),学会查看Request、Response中的信息,主要就是Header的内容。尤其是在重定向时,注意查看Response中的Location的内容是什么。
- 在Chrome的调试窗口中,要勾选Preserve Log选项(如下图),否则在页面跳转等场合会漏掉跳转后的请求记录。
关于HTTPS
应用服务器以HTTP方式部署,通过Apache/Nginx转换成HTTPS给用户访问;
第二种方式是我重点推荐方式。当需要提供HTTPS服务时,只在反向代理Apache/Nginx层进行设置,而源应用总是使用HTTP。这样做的好处是当业务系统很多且都可以使用子路径部署方式时,只需要配置一次证书(因为主URL相同,只有子路径不同),能够减轻配置工作量。当应用服务器需要集群、多个系统多个域名多个入口等情况下,即使需要配置多个证书,能够只在反向代理服务器上一次性干完所有工作,不用慢吞吞地重启Tomcat等应用服务器,毕竟Apache/Nginx都可以秒起甚至热加载,这是何等幸福!这算是一个最佳实践吧,以后有时间我可以再写一篇详细文章介绍。
回到本文主题,前面介绍的方案在这两种场景下是否可以沿用呢?第一种场景我没有试过,估计可行,如果有人试过请留言说一下结果。第二种场景则是亲测通过的,亲们可以放心使用。