注: 这是在之前博客上发过的文章,在实际使用中经常会有人碰到,特意转贴过来。
1. 背景
目前公司对外开放了一个云服务平台,提供一些功能供商户接入使用。整个项目的架构是基于Spring + MyBatis的。另外,商户端的服务接口是基于SOAP WebService的,这部分使用CXF实现。 安全方面采用了Spring Security,可以对商户提供证书认证或密码认证。但是出于安全考虑,目前只开放了证书认证。为了使用证书认证商户,我们创建了一个自签名的CA,用来生成商户使用的客户端证书。在验证上,使用Nginx验证客户端证书是否是指定CA产生的。另外,为了防止被作废的证书(例如给商户颁发了新证书后,原证书应该作废,但是原证书也是由指定CA产生的)再次使用,在代码层面对证书进行了进一步的验证(这一点是通过Nginx将客户端证书作为Header传递到Java后台实现的,有时间以后再讲)。
2. 部署架构
云服务平台部署在aliyun上,大致上结构是这样的(只涉及到了网络访问层面的东西):
1商户 -https-> aliyun负载均衡 -tcp转发-> Nginx -http-> Jetty --> ClientCertificateFromProxyFilter
商户访问云服务的时候,需要使用我们提供的客户端证书来建立https链接。aliyun的LB只负责TCP转发,不对协议进行分析。因此从aliyun到Nginx之间实际的数据流是https数据流。Nginx接受到请求后,验证客户端证书是否正确,并将客户端证书设置为HTTP请求中的Header变量,然后请求后台的Jetty服务器。
我们代码中实现了一个Filter(ClientCertificateFromProxyFilter),它的唯一作用是检查过来的HTTP请求中有没有变量SSL_CLIENT_CERT
,如果有则把它转换成一个Certificate对象,添加到HTTP请求中,从而将一个HTTP请求模拟成一个HTTPS请求,这样Spring Security就能够进行证书认证了。
3. 问题
在上线之前,按照以往的经验,我们测试了通过浏览器访问受保护的资源来测试HTTPS是否工作正常。因为提前在浏览器中导入了客户端证书,因此浏览器上能够弹出对话框选择客户端证书,选择之后就能够访问指定的资源了。
我们推荐商户使用CXF作为接入方式,一般的代码如下:
1<jaxws:client id="uidService"
2 serviceClass="com.xwf.cloudauth...."
3 address="https://.../api/UidApiService">
4</jaxws:client>
5<http-conf:conduit name="*.http-conduit">
6 <http-conf:tlsClientParameters disableCNCheck="true">
7 <sec:keyManagers keyPassword="123456">
8 <sec:keyStore type="PKCS12" password="123456" resource="...com.p12"/>
9 </sec:keyManagers>
10 <sec:trustManagers>
11 <sec:keyStore type="JKS" password="changeit" resource="cacerts" />
12 </sec:trustManagers>
13 </http-conf:tlsClientParameters>
14 <http-conf:client Connection="Keep-Alive"
15 MaxRetransmits="1" AllowChunking="false" />
16</http-conf:conduit>
但是商户实际接入的时候却碰到了问题:
1Caused by: org.apache.cxf.transport.http.HTTPException: HTTP response '401: Full authentication is required to access this resource' when communicating with https://...
2 at org.apache.cxf.transport.http.HTTPConduit$WrappedOutputStream.handleResponseInternal(HTTPConduit.java:1549)
3 at org.apache.cxf.transport.http.HTTPConduit$WrappedOutputStream.handleResponse(HTTPConduit.java:1504)
4 at org.apache.cxf.transport.http.HTTPConduit$WrappedOutputStream.close(HTTPConduit.java:1310)
5 at org.apache.cxf.transport.AbstractConduit.close(AbstractConduit.java:56)
6 at org.apache.cxf.transport.http.HTTPConduit.close(HTTPConduit.java:628)
7 at org.apache.cxf.interceptor.MessageSenderInterceptor$MessageSenderEndingInterceptor.handleMessage(MessageSenderInterceptor.java:62)
8 ... 38 more
4. 解决过程
401错误代表未授权,所以首先怀疑是客户端证书不正确。
4.1 切换到本地测试
收到客户反馈之后,首先想到的可能是证书发放的不正确,毕竟已经通过浏览器通过了测试。 首先在本地测试环境进行了测试。因为之前测试坏境中配置的连接服务的代码中启用了用户名密码认证,因此首先做的是将用户名、密码认证部分屏蔽掉,然后进行测试,结果还真发现了问题。 这下子有点着急了,担心有大的bug存在。毕竟产品已经上线,碰到这种bug可比较麻烦了。
4.2 本地问题的解决
经过添加日志、断点在本地环境进行了调试,找到了本地报401错误的原因:本地环境中的证书是被废弃掉的了,本地环境证书重发后并没有同步到本地SVN中,因此才会出现401错误。证书通过了Nginx的验证,但是被Java层的代码拦截掉(因为客户提交过来的代码与数据库中用户相关的证书不一致)。 通过将本地证书替换之后,问题得到了解决。原来本地环境只是虚惊一场。
4.3 发布调试版本
本地环境问题解决之后,更加怀疑生产环境的问题来源于证书不匹配。但是经过仔细分析客户证书文件及数据库中的记录,发现并不能够支持这种想法。
没有办法的情况下,只好在代码中(主要是ClientCertificateFromProxyFilter)添加了更多的调试信息,临时发布了调试版本。再次测试,发现在ClientCertificateFromProxyFilter中没有收到SSL_CLIENT_CERT
这个变量。初步断定问题出在Nginx转发上(从Nginx到Jetty没有转发客户端证书)。
4.4 Wireshark
确定问题出在Nginx层之后事情陷入了僵局,因为实在不知道为什么会产生这种情况。 因为前一阵子研究过SSL握手的过程,当时使用过Wireshark进行过网络抓包。当事情陷入僵局的时候,想起来可以使用这种方法分析一下协议。经过抓包,比各个环境(测试环境、生产环境/浏览器及Java环境),发现了问题所在,原来在生产环境上Java根本没有发送客户端证书到Nginx上去!
4.5 协议分析
没有办法的情况下,只好回头又去分析SSL协议:
发现在双向认证过程中,在server hello done
结束之前,服务器应该发送certificate request
到客户端。这样客户端才能够决定发送客户端证书到服务器进行认证。但是在生产环境使用Java访问的时候,服务器没有发送该请求;但是奇怪的是当使用浏览器访问的时候,这个请求又出现了。
4.6 SNI
再次陷入了僵局。同样的HTTPS请求为什么会导致不同的认证顺序和结果呢?经过漫长的,漫无目的的搜索后,终于找到了一点眉目。原来这是一个兼容性问题:SNI。 这里先介绍一下SNI的概念:
SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的SSL/TLS扩展。一句话简述它的工作原理就是,在连接到服务器建立SSL连接之前先发送要访问站点的域名(Hostname),这样服务器根据这个域名返回一个合适的证书。目前,大多数操作系统和浏览器都已经很好地支持SNI扩展,OpenSSL 0.9.8已经内置这一功能。
我们的云认证平台后台有很多台服务器,配置了多个域名,但是对外的出口是唯一的,都是使用Nginx作为代理/反向代理的。这样就用到了SNI技术,在一台机器上同时对多个域名提供服务。当浏览器访问的时候,因为浏览器支持SNI技术,因此会提供域名给Nginx,这样Nginx能够从配置好的多个域名配置文件中找到需要的证书及客户端证书CA配置信息进行验证。 但是当Java访问的时候,就碰到了问题:因为Java不能够自动支持SNI协议(Java8中提供了SNI的支持,但是需要手工编码),因此在上面的Spring配置中,并没有办法把域名发送给Nginx,因此Nginx不知道使用哪个配置中的证书及客户端证书CA配置信息。
4.7 解决方法
后来从网上找到了这样一篇文章:Using nginx and client certificate!,这才最终明白了怎么解决问题。
Nginx有一个default server的概念。也就是说当出现不支持SNI协议的客户端时,将使用default server的配置进行验证。当没有配置default server的时候,Nginx将使用找到的第一个配置文件中的配置(经过测试,应该是按照字母顺序排序的)。 而在我们生产环境的配置中,第一个找到的配置文件中没有指定客户端证书CA!!
1server {
2 listen 443;
3 server_name ...xwf-id.com;
4 ssl on;
5 ssl_certificate /etc/nginx/keys/xwfserver.pem;
6 ssl_certificate_key /etc/nginx/keys/xwfserver.key;
7 ssl_session_timeout 5m;
8 ssl_verify_client off;
9
10 location / {
11 proxy_buffer_size 8k;
12 proxy_buffers 8 64k;
13 proxy_buffering on;
14 proxy_pass http://prm_admin;
15 proxy_set_header Host $host;
16 proxy_set_header X-Real-IP $remote_addr;
17 proxy_set_header X-Forwarded-Host $host;
18 proxy_set_header X-Forwarded-Server $host;
19 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
20 }
21}
所以当Java访问的时候,服务器端告诉客户端,不需要提供客户端证书。。。
4.8 安全考虑
最后,我们添加了一个default server来解决这个问题:
1server {
2 listen 443 default_server;
3 server_name a;
4 ssl on;
5 ssl_certificate /etc/nginx/keys/xwfserver.pem;
6 ssl_certificate_key /etc/nginx/keys/xwfserver.key;
7 ssl_session_timeout 5m;
8 ssl_client_certificate /etc/nginx/keys/ca-certs.pem;
9 ssl_verify_client optional_no_ca;
10
11 location / {
12 return 200;
13 }
14}
这里需要说明的一个问题是:因为我们服务器上有两个服务器要进行客户端证书认证,因此有两个自签名的CA存在。为了解决能够验证两个证书CA签名的证书,在/etc/nginx/keys/ca-certs.pem
中需要同时包含两个CA证书。
但是这也带来了一定的风险:使用网站A证书认证的用户有可能访问到网站B的内容!
但是因为我们目前的体系中,使用ClientCertificateFromProxyFilter
对证书有效性进行了进一步的验证,所以这一问题在我们的系统中没有导致风险。如果其他系统中要用到这个功能,需要对这部分的风险性投入关注。
5. 总结
问题终于解决了,看看时间已经是凌晨5点了。总结一下经验教训:
- 通过这次事故,对SSL通讯的握手过程有了进一步的了解。
- 对Wireshark也有了更进一步的了解。
- 以后产品上线的时候,还是要使用真正的代码进行以下测试,这样就能够今早发现这个Java的SNI兼容性问题了。