浅谈https中的双向认证

总述

https简单来说就是在http协议的基础上增加了一层安全协议。通常为TLS或者SSL(一般现在都采用TLS,更加安全)。这一层安全协议的最主要的作用有两个:
1. 验证服务端或客户端的合法性
2. 商量出最终用来http通信的对称加密秘钥
本次仅仅讲第1点

单向认证与双向认证

所谓的认证既确认对方身份,单向认证一般是指客户端确认服务端身份,双向认证则是指在客户端需要确认服务端身份的同时,服务端也需要确认客户端的身份。
具体可以通过下面两幅图来看下区别:

单向认证

image

双向认证

image


show me the code

这里给出在使用httpClient的时候如何初始化连接池。
public class HttpClientInitConfig {
    
    /**
     * ssl双向认证证书 默认客户端不验证服务端返回
     */
    private TrustStrategy trustStrategy = TrustAllStrategy.INSTANCE;
    
    private String sslProtocol = "TLSV1.2";
    
    /**
     * ssl双向认证客户端的keystore
     */
    private String keyStorePath;
    
    /**
     * ssl双向认证客户端keystore的秘钥
     */
    private String storePwd;
    
    /**
     * ssl双向认证客户端私钥证书密码
     */
    private String keyPwd;
    
    /**
     * 秘钥库证书类型
     */
    private String keyStoreType = "PKCS12";
    
    public String getKeyStoreType() {
        return keyStoreType;
    }

    public void setKeyStoreType(String keyStoreType) {
        this.keyStoreType = keyStoreType;
    }

    private int maxPerRoute = 200;
    
    private int maxTotal = 200;
    
    private int validateAfterInactivity = 60000;

    public int getValidateAfterInactivity() {
        return validateAfterInactivity;
    }

    public void setValidateAfterInactivity(int validateAfterInactivity) {
        this.validateAfterInactivity = validateAfterInactivity;
    }

    public TrustStrategy getTrustStrategy() {
        return trustStrategy;
    }

    public void setTrustStrategy(TrustStrategy trustStrategy) {
        this.trustStrategy = trustStrategy;
    }

    public String getSslProtocol() {
        return sslProtocol;
    }

    public void setSslProtocol(String sslProtocol) {
        this.sslProtocol = sslProtocol;
    }

    public String getKeyStorePath() {
        return keyStorePath;
    }

    public void setKeyStorePath(String keyStorePath) {
        this.keyStorePath = keyStorePath;
    }

    public String getStorePwd() {
        return storePwd;
    }

    public void setStorePwd(String storePwd) {
        this.storePwd = storePwd;
    }

    public String getKeyPwd() {
        return keyPwd;
    }

    public void setKeyPwd(String keyPwd) {
        this.keyPwd = keyPwd;
    }

    public int getMaxPerRoute() {
        return maxPerRoute;
    }

    public void setMaxPerRoute(int maxPerRoute) {
        this.maxPerRoute = maxPerRoute;
    }

    public int getMaxTotal() {
        return maxTotal;
    }

    public void setMaxTotal(int maxTotal) {
        this.maxTotal = maxTotal;
    }
}
public class CacheablePooledHttpClient {
    private static final Logger LOG = LoggerFactory.getLogger(CacheablePooledHttpClient.class);
    
    private Map<String, HttpClient> httpClientMap = new ConcurrentHashMap<>();

    private Map<String, HttpClientConnectionManager> connectionManagerMap = new ConcurrentHashMap<>();
    
    CacheablePooledHttpClient() {
    }
    
    private static final class InstanceHolder {
        static final CacheablePooledHttpClient instance = new CacheablePooledHttpClient();
    }
    
    public static CacheablePooledHttpClient getInstance() {
        return InstanceHolder.instance;
    }
    
    public HttpClient getHttpClient(String clientKey) {
        return this.httpClientMap.get(clientKey);
    }

	public HttpClient initHttpClient(String clientKey, HttpClientInitConfig initConfig) {
        if (this.httpClientMap.containsKey(clientKey)) {
            return this.httpClientMap.get(clientKey);
        }
        
        synchronized (httpClientMap) {
            if (this.httpClientMap.containsKey(clientKey)) {
                return this.httpClientMap.get(clientKey);
            }
            try {
                SSLContextBuilder sslContextBuilder = SSLContexts.custom().setProtocol(initConfig.getSslProtocol()).loadTrustMaterial(initConfig.getTrustStrategy());
                // ssl双向认证的时候,客户端需要加载的证书
                if (StringUtils.isNotBlank(initConfig.getKeyStorePath()) && StringUtils.isNotBlank(initConfig.getStorePwd())) {
                    final KeyStore ks = CryptUtils.loadKeyStore(initConfig.getKeyStorePath(), initConfig.getStorePwd().toCharArray(), initConfig.getKeyStoreType());
                    sslContextBuilder.loadKeyMaterial(ks, initConfig.getKeyPwd().toCharArray(), (a, b) -> {
                            try {
                                return CryptUtils.getFirstAliase(ks);
                            } catch (KeyStoreException e) {
                                throw new HttpClientInitException(e);
                            }
                        });
                    LOG.info("使用客户端证书【{}】初始化http连接池", initConfig.getKeyStorePath());
                }
                SSLContext sslContext = sslContextBuilder.build();
                Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory> create()
                        .register("http", PlainConnectionSocketFactory.getSocketFactory())
                        .register("https", new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE)).build();

                @SuppressWarnings("resource")
                PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry);
                connectionManager.setValidateAfterInactivity(initConfig.getValidateAfterInactivity());    // 半连接状态关闭时间
                connectionManager.setDefaultMaxPerRoute(initConfig.getMaxPerRoute());
                connectionManager.setMaxTotal(initConfig.getMaxTotal());
                connectionManagerMap.put(clientKey, connectionManager);
                httpClientMap.put(clientKey, new HttpClient(initHttpClient(connectionManager)));
                LOG.info("初始化key为【{}】的httpClient连接池成功!", clientKey);
            } catch (Exception ex) {
                throw new HttpClientInitException(ex);
            }

            try {
                Runtime.getRuntime().addShutdownHook(new ContainerShutdownHook(this));
            } catch (Exception e) {
                LOG.error("添加关闭钩子异常!", e);
            }
        }
        
        
        return httpClientMap.get(clientKey);
    }
	
	private CloseableHttpClient initHttpClient(PoolingHttpClientConnectionManager connectionManager) {
        RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(5000).setSocketTimeout(300000).setConnectionRequestTimeout(30000)
                .build();
        return HttpClients.custom().setDefaultRequestConfig(requestConfig).setConnectionManager(connectionManager)
                .setConnectionManagerShared(false).setRetryHandler(new DefaultHttpRequestRetryHandler(0, false)).evictExpiredConnections()
                .evictIdleConnections(1800L, TimeUnit.SECONDS).build();
    }
    
    public void close() {
        try {
            for (HttpClient client : this.httpClientMap.values()) {
                if (client != null) {
                    client.getCloseableHttpClient().close();
                }
            }

            for (HttpClientConnectionManager connectionManager : this.connectionManagerMap.values()) {
                if (connectionManager != null) {
                    connectionManager.shutdown();
                }
            }

            LOG.info("Close httpClient completed");
        } catch (Exception e) {
            LOG.error("shutdown httpcliet exception: ", e);
        }
    }
}
这里其实本来是双向认证的,但是因为时间原因所以偷了个懒,略过了客户端对服务器端证书的校验,而直接使用`TrustAllStrategy.INSTANCE`。其实如果客户端需要对服务器端证书进行校验的话可以参考如下代码设置`trustStrategy`:
KeyStore trustKeyStore = KeyStore.getInstance("jks");
trustKeyStore.load(new FileInputStream("D:\\trustkeystore.jks"), "123456".toCharArray());
sslContextBuilder.loadTrustMaterial(trustKeyStore, new TrustSelfSignedStrategy());

小结

  • 证书是存在证书链的,根证书能对所有子证书进行验证,在进行双向认证的时候服务端和客户端需要初始化的证书都是从根证书生成的
  • 在TLS协议过程中发送的客户端和服务端证书(.crt)其实都是公钥证书,外加一些版本号、身份、签名等信息
  • 客户端可以通过使用TrustAllStrategy来忽略对服务器证书中的身份校验,而仅仅是去拿到证书里面的公钥
  • 如果服务端对客户端证书有校验,而客户端在使用HttpClient请求的时候未loadKeyMaterial发送客户端证书,则会报类似如下错误:
javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake
Caused by: javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake
        at sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1367) ~[?:1.8.0_192]
        at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1395) ~[?:1.8.0_192]
        at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1379) ~[?:1.8.0_192]
        at sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1367) ~[?:1.8.0_192]
        at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1395) ~[?:1.8.0_192]
        at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1379) ~[?:1.8.0_192]
  • 如果客户端未使用TrustAllStrategy初始化HttpClient且指定对服务端的域名校验器不是NoopHostnameVerifier.INSTANCE, 那么如果服务端生成证书时指定的域名/ip不是服务端实际域名/ip。那么会报类似如下错误:
Certificate for <xxxx> doesn't match any of the subject alternative name 

其他常见错误

服务端证书验证不通过导致的报错

certificate error
	java.security.cert.CertificateException: No name matching localhost found
或者
	java.security.cert.CertificateException: No subject alternative names matching IP address xxx
解释
	While making an SSL connection, HttpsClient steps in and does basic server authentication to protect against URL spoofing 
which includes verifying that the name of the server is found in the certificate. 
HttpsClient#checkURLSpoofing method checks server identity according to "RFC 2818: HTTP over TLS" - "Section 3.1 Server Identity".

      HttpsClient basically uses HostNameChecker first to check the hostname against the names specified in the certificate. 
Then, if it fails, HostNameVerifier's turn comes and it's used to verify the host name. As mentioned above, 
while not overridden, SUN's default behaviour is returning false for this verification. 
This means, if your HostNameChecker fails, you will get one of the exceptions written in the title according to your URL's hostname type.

So, what can be done to "not-fail" HostNameChecker?

HostNameChecker#match method's implementation is like below;

sun.security.util.HostNameChecker
public  void  match ( String hostName, X509Certificate x509certificate )  throws  CertificateException  {
     if  ( isIpAddress ( hostName )) {
         matchIP ( hostName, x509certificate ) ;
     }  else  {
         matchDNS ( hostName, x509certificate ) ;
     }
}
If the incoming hostname is IP, (by matchIP method), it will be searched in available subject alternative names and throw CertificateException
("No subject alternative names matching IP address ...") if no matching ip value found.

On the other hand, if the incoming hostname is DNS, (by matchDNS method), it will be searched in available subject alternative names but, 
different from IP matching algorithm, DNS matching will compare the hostname with the CommonName value from certificate if available. 
If neither matches with the hostname, a CertificateException("No name matching ... found") will be thrown.

What we can conclude from these details is;

if you'd like to connect via using IP as hostname;
your certificate should include that ip value as a subject alternative name value (of type IPAddress : key=7).
if you'd like to connect via using DNS as hostname;
your certificate should either include that DNS name as a subject alternative name value (of type DNS : key=2) or as a CommonName(CN) value.
posted @ 2021-07-22 19:03  Secondworld  阅读(3140)  评论(0编辑  收藏  举报