對接服務(wù)升級后僅支持tls1.2,jdk1.7默認使用tls1.0,導(dǎo)致調(diào)用失敗
背景
如標題所說,我手里維護了一個重要的老項目,使用jdk1.7,里面對接了很多個第三方服務(wù),協(xié)議多種多樣,其中涉及http/https的,調(diào)用方式也是五花八門,比如:commons-httpclient、apache httpclient、原生的url.openConnection()等。
<dependency>
<groupId>commons-httpclient</groupId>
<artifactId>commons-httpclient</artifactId>
<version>3.0</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.3</version>
</dependency>
最近,其中一個服務(wù)方,因為網(wǎng)絡(luò)設(shè)備要加固、網(wǎng)絡(luò)安全等原因,準備不再支持https的sslv3、tls1.0、tls1.1了,只支持tls1.2和tls1.3.
這邊服務(wù)方也比較猛,直接就升級了,升級后沒一會,他觀察影響到我們這邊的調(diào)用了,又回退了。
目前就是希望我們這邊,作為客戶端,先升級到tls1.2,即:調(diào)用他們服務(wù)的時候,使用tls1.2去調(diào)用。
本來我也不想動,你個服務(wù)端,安安心心地兼容下tls1.0、tls1.1,不是簡單的很嗎,最終拉扯了一頓,行吧,那就我們先研究下,看看好不好升級到tls1.2。如果實在不好弄,到時候直接改成http調(diào)用得了,搞啥https?
研究下來的方案,感覺還湊合,然后就改了,已經(jīng)提交測試了,今天就先記錄一下。
報錯現(xiàn)象
我在網(wǎng)上找了個工具,可以測試目標https網(wǎng)站,支持哪幾個版本的tls,如下所示,-p指定端口,后面的www.baidu.com就是目標ip或者域名。
nmap --script ssl-enum-ciphers -p 443 www.baidu.com

比如上圖的百度,就還在兼容老版本。
我在網(wǎng)上又試了幾個域名,找到了一個只支持tls1.2的。
blog.csdn.net

下面,我們就拿blog.csdn.net舉例,看看用tls1.0發(fā)送請求,會報什么錯:

可以看到,當(dāng)我們?nèi)挝帐滞瓿桑l(fā)送了第一個ssl握手消息(client hello,版本為tlsv1)后,對方(blog.csdn.net)直接來了個Alert,然后服務(wù)端就主動斷開socket了。這,連接都建不起來,還怎么消息交互呢,自然是所有調(diào)用全部失敗。
報錯代碼debug
sslcontext獲取
給大家看下我們這邊調(diào)用發(fā)起的代碼,這個代碼就是用的上面說的那個commons-httpclient包,這個包算是apache早期維護的http調(diào)用工具,后來慢慢就重心不在這里了,轉(zhuǎn)到了apache httpclient。
https://hc.apache.org/httpclient-legacy/

HttpClient httpClient = new HttpClient();
httpClient.getParams().setContentCharset(charset);
httpClient.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(0,false));
// 1 根據(jù)url,生成要調(diào)用的http method
PostMethod httpMethod = new PostMethod(urlPath);
try
{
long t1 = System.currentTimeMillis();
// 2 實際發(fā)起調(diào)用
int statusCode = httpClient.executeMethod(httpMethod);
long spendTime = System.currentTimeMillis() - t1;
...
}
從前面報錯的原因看來也是挺清晰的,那就是看怎么改了。我也在網(wǎng)上查了查,很多就說,設(shè)置個system property就行了。
System.setProperty("https.protocols", "TLSv1.2");
或者
虛擬機參數(shù)設(shè)置 -Dhttps.protocols=TLSv1.2
結(jié)果,我設(shè)置后,發(fā)現(xiàn)沒什么效果。沒效果的話,我一般會先debug試試,看看為什么發(fā)出去的報文是tls1.0.
從如下地方開始debug代碼,因為實際執(zhí)行連接是在這里:
int statusCode = httpClient.executeMethod(httpMethod);

然后進入下圖,交給一個叫HttpMethodDirector的類執(zhí)行,這個類的注釋是說:負責(zé)執(zhí)行,及一些認證、重定向、報錯重試等相關(guān)事情

后續(xù)會進入到:org.apache.commons.httpclient.HttpMethodDirector#executeWithRetry
這里要先打開socket連接:

這個conn.open比較重要:
下面1處,判斷是否是https調(diào)用,且未使用代理,如果是的話,最終就是要走ssl握手那一套,構(gòu)造的socket也不一樣,如javax.net.ssl.SSLSocket
2處,如果是https調(diào)用但使用了代理,這里就用普通http(不知道為啥),但反正我們沒使用代理,不涉及。
3處,就是我們會進入的分支,獲取對應(yīng)的ProtocolSocketFactory(org.apache.commons.httpclient.protocol.ProtocolSocketFactory,該框架中的一個接口,反正是負責(zé)創(chuàng)建socket的)
4處,創(chuàng)建socket
org.apache.commons.httpclient.HttpConnection#open
public void open() throws IOException {
LOG.trace("enter HttpConnection.open()");
final String host = (proxyHostName == null) ? hostName : proxyHostName;
final int port = (proxyHostName == null) ? portNumber : proxyPortNumber;
try {
if (this.socket == null) {
// 1
usingSecureSocket = isSecure() && !isProxied();
ProtocolSocketFactory socketFactory = null;
// 2
if (isSecure() && isProxied()) {
Protocol defaultprotocol = Protocol.getProtocol("http");
socketFactory = defaultprotocol.getSocketFactory();
} else {
// 3
socketFactory = this.protocolInUse.getSocketFactory();
}
// 4
this.socket = socketFactory.createSocket(
host, port,
localAddress, 0,
this.params);
}
socket.setTcpNoDelay(this.params.getTcpNoDelay());
socket.setSoTimeout(this.params.getSoTimeout());
inputStream = new BufferedInputStream(socket.getInputStream(), inbuffersize);
outputStream = new BufferedOutputStream(socket.getOutputStream(), outbuffersize);
isOpen = true;
} catch (IOException e) {
throw e;
}
}
這里,我們默認會走到上面3處,工廠類型為:org.apache.commons.httpclient.protocol.SSLProtocolSocketFactory,這個是默認的工廠。
其中,我們來看看是如何createSocket的:
這里會調(diào)用javax.net.ssl.SSLSocketFactory#getDefault,可以從包名看到,已經(jīng)開始和jdk中ssl部分的類交互了:

在jdk 1.7的javax.net.ssl.SSLSocketFactory中,有一個static的全局變量,theFactory。

我們看看這個getdefault的邏輯:
1處,如果static field不為空,直接返回這個field。
2處,如果自己指定了ssl.SocketFactory.provider,也可以用我們自定義的,我沒用這種方法,跳過
3處,SSLContext.getDefault()獲取到一個SSLContext,然后調(diào)用javax.net.ssl.SSLContext#getSocketFactory來獲取一個factory。
public static synchronized SocketFactory getDefault() {
if (theFactory != null) {
// 1
return theFactory;
} else {
if (!propertyChecked) {
propertyChecked = true;
// 2
String var0 = getSecurityProperty("ssl.SocketFactory.provider");
if (var0 != null) {
...
Class var1 = = Class.forName(var0);
SSLSocketFactory var2 = (SSLSocketFactory)var1.newInstance();
// 2.1 設(shè)置theFactory
theFactory = var2;
return var2;
}
}
try {
// 3
return SSLContext.getDefault().getSocketFactory();
} catch (NoSuchAlgorithmException var4) {
return new DefaultSSLSocketFactory(var4);
}
}
}
接下來,我們重點看看3處:

這里會獲取靜態(tài)字段SSLContext defaultContext,如果為null就先初始化:
private static SSLContext defaultContext;
初始化的邏輯,就是傳個Default進去,那出來的是啥呢:
下面這個地方可以簡述一下,大家看到SSLContextSpi.class了,Spi什么意思,ServiceProviderInterface,反正就是java官方負責(zé)定接口,廠商負責(zé)提供實現(xiàn)類,然后通過在某個配置文件中指定要使用的實現(xiàn)類來實現(xiàn)動態(tài)切換實現(xiàn)的效果。
public static SSLContext getInstance(String var0) throws NoSuchAlgorithmException {
Instance var1 = GetInstance.getInstance("SSLContext", SSLContextSpi.class, var0);
return new SSLContext((SSLContextSpi)var1.impl, var1.provider, var0);
}
大家看看:SSLContextSpi是在javax.net.ssl包下面,而其實現(xiàn),則是在sun包下了。

那,這里前面?zhèn)髁藗€“Default”進來,會獲取到哪一種SSLContext呢,我們看到實現(xiàn)類有這么多:

結(jié)果,取到的就是:sun.security.ssl.SSLContextImpl.DefaultSSLContext#DefaultSSLContext

這里只說是默認,默認是什么意思,咱們也不知道,但是,有經(jīng)驗的,對這塊代碼熟悉的,可能知道,大概問題就在這附近了,如果這里能拿到sun.security.ssl.SSLContextImpl.TLS12Context,說不定,問題就解決了。
DefaultSSLContext
這個DefaultSSLContext繼承了ConservativeSSLContext:
public static final class DefaultSSLContext extends SSLContextImpl.ConservativeSSLContext
在ConservativeSSLContext中,有如下的幾個field,其中defaultClientSSLParams對我們來說,最重要:
private static class ConservativeSSLContext extends SSLContextImpl {
private static final SSLParameters defaultServerSSLParams;
// 重要
private static final SSLParameters defaultClientSSLParams;
private static final SSLParameters supportedSSLParams = new SSLParameters();
下圖這里可以看到,defaultClientSSLParams最終被設(shè)置為從var1(tlsv1、sslv3)中獲取getAvailableProtocols,而這getAvailableProtocols會排除掉sslv3,只剩下tls v1。


如果我們此時看看tlsv2對應(yīng)的sun.security.ssl.SSLContextImpl.TLS12Context:

人家這里就支持的多了去了:sslv3 tls1.0 tls1.1 tls1.2
SSLContext#getSocketFactory
我們此時完成了SSLContext的構(gòu)建,然后看看怎么構(gòu)造socketFactory。
實際上,構(gòu)造socketFactory沒做啥事,只是new了一個sun.security.ssl.SSLSocketFactoryImpl,然后把context包裝了下。

createSocket
public Socket createSocket(String var1, int var2, InetAddress var3, int var4) throws IOException {
return new SSLSocketImpl(this.context, var1, var2, var3, var4);
}

這個init,也比較重要,就用到了我們前面的defaultClientSSLParams:

最終,就導(dǎo)致:sun.security.ssl.SSLContextImpl#defaultClientProtocolList也變成了僅包含tlsv1

然后呢,sun.security.ssl.SSLSocketImpl#enabledProtocols也就變成了tlsv1

接下來,開始三次握手(如下的:super.connect),


然后,在三次握手后,初始化ssl握手:
void doneConnect() throws IOException {
if (this.self == this) {
this.sockInput = super.getInputStream();
this.sockOutput = super.getOutputStream();
} else {
this.sockInput = this.self.getInputStream();
this.sockOutput = this.self.getOutputStream();
}
//
this.initHandshaker();
}
初始化握手對象
private void initHandshaker() {
switch(this.connectionState) {
case 0:
case 2:
this.handshaker = new ClientHandshaker(this, this.sslContext, this.enabledProtocols, this.protocolVersion, this.connectionState == 1, this.secureRenegotiation, this.clientVerifyData, this.serverVerifyData);
this.handshaker.setEnabledCipherSuites(this.enabledCipherSuites);
this.handshaker.setEnableSessionCreation(this.enableSessionCreation);
return;

此時,把版本繼續(xù)傳遞給了handshaker:

至此,createSocket這個方法就完成了,但是,我們現(xiàn)在只是完成了三次握手,ssl中的clienthello消息還沒開始發(fā)送呢。
httpclient.HttpMethod#execute
我們一路回到了org.apache.commons.httpclient,開始執(zhí)行如下的execute:



接下來,看到sslSocketImpl在寫消息的時候,要先進行ssl握手:

handshaker.activate
注意,如下這處,取了activeProtols中的最大的那個協(xié)議,而我們目前activeProtols這個list中,只有tlsv1,所以取到的自然就是tlsv1,然后賦值給了this.protocolVersion:

接下來,又使用了this.protocolVersion:

handshaker.kickstart

接下來,在構(gòu)造消息時,還是使用了this.protocolVersion:
這里有點意思的是,紅框處,是將this.protocolVersion賦值給了this.maxProtocolVersion,說明我們握手消息里的那個version,其實指的是客戶端支持的最大版本:

基于這個,我在網(wǎng)上查找了一下,確實是這樣:
tls1.0:
https://www.ietf.org/rfc/rfc2246.txt

tls1.1:
https://datatracker.ietf.org/doc/html/rfc4346

版本號驗證
此時,我們基本也完成了關(guān)于版本號是怎么一步一步設(shè)置的過程的研究,最終,就會指定到如下圖的位置:

不抓包如何查看使用的版本
-Djavax.net.debug=ssl:handshake:verbose
或者
System.setProperty("javax.net.debug","ssl:handshake:verbose");
然后標準輸出中會打印很多握手消息,可以搜索: ClientHello ,就能看到用的啥。
如何解決該問題
可選方案
針對不同的http調(diào)用方式,方法不一樣,如,對于原生的URL、httpUrlConnection等,用以下方法基本夠了:
System.setProperty("https.protocols", "TLSv1.2");
或者
虛擬機參數(shù)設(shè)置 -Dhttps.protocols=TLSv1.2
使用apache httpclient的話,網(wǎng)上找下吧,方式很多,框架本身就支持指定。
如果你們也有老項目,使用我這里的commons httpclient的話:
可以先看下如下文章:https://blog.csdn.net/jilo88/article/details/123424442
這個方法的重點就在于:

我們前面提到過,以下代碼,默認返回的是:sun.security.ssl.SSLContextImpl.DefaultSSLContext

而上述文章中,就是先自己手動指定了1.2:
SSLContext sc = SSLContext.getInstance("TLSv1.2");
然后設(shè)置到了這個javax.net.ssl.SSLContext#defaultContext。
這個方式,影響很深遠,因為這個是一個靜態(tài)變量,整個jdk也就這一個SSLContext類,也就這一個靜態(tài)變量,所以是全局的影響。
我試過了,改這里,會導(dǎo)致使用原生的URL、httpUrlConnection的方式的代碼也受到影響,大家可以自己試試。
apache httpclient,有沒有影響,我有點忘了,大家自己測下。
我的方案
我是希望使用影響最小的方法,我如下的方法,只影響使用commons httpclient這種框架的,不使用這種框架的,不會受到影響。
commons httpclient支持對于https,注冊自己的socketFactory:

我這邊給https自定義了一個ProtocolSocketFactory,代碼很簡單,大家只要找個合適的時機(如發(fā)起http調(diào)用之前),調(diào)用一次如下的init方法,就可以了
import org.apache.commons.httpclient.protocol.Protocol;
import org.apache.commons.httpclient.protocol.ProtocolSocketFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
public class HttpClientSupport {
private static Logger logger = LoggerFactory.getLogger(HttpClientSupport.class);
public static void init(){
Protocol protocol = Protocol.getProtocol("https");
if (protocol != null) {
ProtocolSocketFactory socketFactory = protocol.getSocketFactory();
if (socketFactory instanceof CustomSSLProtocolSocketFactory){
// logger.info("already registered");
return;
}
logger.error("registered protocol for https is not CustomSSLProtocolSocketFactory type,will register");
}
// 注冊自定義的 ProtocolSocketFactory 到 HTTPS 協(xié)議
CustomSSLProtocolSocketFactory socketFactory = null;
try {
socketFactory = new CustomSSLProtocolSocketFactory();
Protocol.registerProtocol("https", new Protocol("https", socketFactory, 443));
logger.info("register tls1.2 socket factory success");
} catch (NoSuchAlgorithmException | KeyManagementException e) {
logger.error("err",e);
}
}
}
import org.apache.commons.httpclient.ConnectTimeoutException;
import org.apache.commons.httpclient.params.HttpConnectionParams;
import org.apache.commons.httpclient.protocol.SecureProtocolSocketFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
/**
* oa側(cè)升級tls協(xié)議為tls1.2及以上,我方進行適配
*/
public class CustomSSLProtocolSocketFactory implements SecureProtocolSocketFactory {
private static Logger logger = LoggerFactory.getLogger(CustomSSLProtocolSocketFactory.class);
private final SSLContext sslContext;
public CustomSSLProtocolSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
sslContext = SSLContext.getInstance("TLSv1.2");
// 初始化 SSLContext(使用默認的 TrustManager)
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
// 信任所有客戶端證書
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
// 信任所有服務(wù)器證書
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
}
/**
* @see SecureProtocolSocketFactory#createSocket(java.lang.String,int,java.net.InetAddress,int)
*/
public Socket createSocket(
String host,
int port,
InetAddress clientHost,
int clientPort)
throws IOException, UnknownHostException {
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
// logger.info("socketFactory:" + socketFactory);
return socketFactory.createSocket(
host,
port,
clientHost,
clientPort
);
}
public Socket createSocket(
final String host,
final int port,
final InetAddress localAddress,
final int localPort,
final HttpConnectionParams params
) throws IOException, UnknownHostException, ConnectTimeoutException {
if (params == null) {
throw new IllegalArgumentException("Parameters may not be null");
}
int timeout = params.getConnectionTimeout();
if (timeout == 0) {
return createSocket(host, port, localAddress, localPort);
} else {
logger.error("not support connection timeout param");
return createSocket(host, port, localAddress, localPort);
}
}
/**
* @see SecureProtocolSocketFactory#createSocket(java.lang.String,int)
*/
public Socket createSocket(String host, int port)
throws IOException, UnknownHostException {
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
return socketFactory.createSocket(
host,
port
);
}
/**
* @see SecureProtocolSocketFactory#createSocket(java.net.Socket,java.lang.String,int,boolean)
*/
public Socket createSocket(
Socket socket,
String host,
int port,
boolean autoClose)
throws IOException, UnknownHostException {
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
return socketFactory.createSocket(
socket,
host,
port,
autoClose
);
}
/**
* All instances of CustomSSLProtocolSocketFactory are the same.
*/
public boolean equals(Object obj) {
return ((obj != null) && obj.getClass().equals(CustomSSLProtocolSocketFactory.class));
}
/**
* All instances of CustomSSLProtocolSocketFactory have the same hash code.
*/
public int hashCode() {
return CustomSSLProtocolSocketFactory.class.hashCode();
}
}
參考資料
https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/ReadDebug.html
總結(jié)
這個問題能解決,說白了,還是因為jdk1.7本來就支持tls1.2,只是因為默認用了tls.10,這里只是強制指定下。
希望能解決大家的問題就行了,維護老項目,處處小心點即可。今年估計要開始學(xué)python了,有領(lǐng)導(dǎo)安排的其他任務(wù),量化什么的,python更適合點,所以以后學(xué)廢了的話,可能也會更新一些java語言之外的。

浙公網(wǎng)安備 33010602011771號