LDAPS: 必要證書擴展缺失導致SSL連接建立失敗解決辦法


2020年4月1日 更新:

解決在OpenJDK11下Spring Boot FatJar拋出ClassNotFoundException的問題。詳見Spring Boot Fat Jar 運行異常

問題復現

環境

AD由測試部署在Windows Server 2008上面,服務端證書也是Windows簽發的
客戶端:OpenJDK11(此問題在OpenJDK8+都會出現)


可以直接跳到后面的解決方案一節查看處理

通過SSL連接LDAP時,會拋出如下異常(精簡后)

1
2
3
4
5
6
7
8
9
10
11
javax.net.ssl|DEBUG|01|main|2020-01-05 13:14:47.338 CST|SSLCipher.java:437|jdk.tls.keyLimits:  entry = AES/GCM/NoPadding KeyUpdate 2^37. AES/GCM/NOPADDING:KEYUPDATE = 137438953472
....省略
Caused by: java.security.cert.CertificateException: No subject alternative names matching IP address 10.20.61.26 found
at java.base/sun.security.util.HostnameChecker.matchIP(HostnameChecker.java:160)
at java.base/sun.security.util.HostnameChecker.match(HostnameChecker.java:96)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:463)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:434)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:233)
at java.base/sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:129)
at java.base/sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:626)
... 26 more

主要就是因為檢查服務端證書的特定擴展失敗,證書中沒有對應的擴展。LDAPS對應的SSL證書需要驗證IP或者DNS擴展才可以

再看下SSL流的追蹤

最后顯示未知證書,同時也印證了上面的異常堆棧信息。從而我們知道是證書出的問題

問題分析

定位

證書檢查異常,回過頭去翻一下LDAPS的RFC文檔,在RFC4519第3.1.3.服務端身份認證一節,存在三種認證方式:

  • 比較DNS
  • 比較IP
  • 比較其他SN類型

都是提取Extensions里面的subjectAlternativeName(oid: 2.5.29.17),主要涉及GeneralNameDNSNameiPAddress兩種類型。當證書中不存在相應擴展,或者對應擴展的類型有誤,都會校驗失敗

解決方案

重新實現一個SSLSocketFactory,不驗證證書等信息即可:

LdapsNoVerifySSLSocketFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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.SecureRandom;
import java.security.Security;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509ExtendedTrustManager;

/**
* Do not verify cert IP or DNS Extensions.
*
* @author <a href="mailto:gsealy@outlook.com">Gsealy</a>
*/
public class LdapsNoVerifySSLSocketFactory extends SSLSocketFactory {

static {
Security.setProperty("ssl.SocketFactory.provider",
LdapsNoVerifySSLSocketFactory.class.getName());
}

private final SSLContext sslContext;

private final SSLSocketFactory socketFactory;

public LdapsNoVerifySSLSocketFactory() throws NoSuchAlgorithmException, KeyManagementException {
NoVerificationTrustManager noVerificationTrustManager = new NoVerificationTrustManager();
sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{noVerificationTrustManager}, new SecureRandom());
socketFactory = sslContext.getSocketFactory();
SSLContext.setDefault(sslContext);
}

@Override
public String[] getDefaultCipherSuites() {
return socketFactory.getDefaultCipherSuites();
}

@Override
public String[] getSupportedCipherSuites() {
return socketFactory.getSupportedCipherSuites();
}

@Override
public Socket createSocket(Socket s, String host, int port, boolean autoClose)
throws IOException {
return socketFactory.createSocket(s, host, port, autoClose);
}

@Override
public Socket createSocket(String host, int port) throws IOException {
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
return this.socketFactory.createSocket(host, port);
}

@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
throws IOException, UnknownHostException {
return socketFactory.createSocket(host, port, localHost, localPort);
}

@Override
public Socket createSocket(InetAddress host, int port) throws IOException {
return socketFactory.createSocket(host, port);
}

@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress,
int localPort) throws IOException {
return socketFactory.createSocket(address, port, localAddress, localPort);
}

static class NoVerificationTrustManager extends X509ExtendedTrustManager {
@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String authType,
Socket socket) throws CertificateException {
}

@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String authType,
SSLEngine engine) throws CertificateException {
}


public void checkServerTrusted(X509Certificate[] x509Certificates, String authType,
Socket socket) throws CertificateException {
}

@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String authType,
SSLEngine engine) throws CertificateException {
}

@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String s)
throws CertificateException {
}

@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String s)
throws CertificateException {
}

@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
}

JNDI連接LDAP示例代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;

import org.apache.directory.api.ldap.model.constants.JndiPropertyConstants;

import com.sun.jndi.ldap.LdapCtxFactory;

public class LdapsJNDITest {

public static void main(String[] args) {
Hashtable<String, String> env = new Hashtable<>();
String ldapURL = "ldaps://10.20.61.26:636";
String adminName = "CN=Administrator,CN=Users,DC=aaa,DC=com";
String adminPassword = "11111111";
env.put(Context.INITIAL_CONTEXT_FACTORY, LdapCtxFactory.class.getName());
env.put(Context.SECURITY_PRINCIPAL, adminName);
env.put(Context.SECURITY_CREDENTIALS, adminPassword);
env.put(JndiPropertyConstants.JNDI_FACTORY_SOCKET, LdapsNoVerifySSLSocketFactory.class.getName());

try {env.put(Context.PROVIDER_URL, ldapURL);
LdapContext ctx = new InitialLdapContext(env, null);
} catch (NamingException e) {
e.printStackTrace();
}
}

}

附:JNDI LDAP連接流程

先創建一個配置的Map,這里用的是HashTable,因為上下文初始化的方法簽名是hashtable

1
2
// 初始化的方法簽名
javax.naming.InitialContext#InitialContext(java.util.Hashtable<?,?>)

若選擇LDAPS,最少需要如下參數

1
2
3
4
5
6
7
8
9
10
# 初始化上下文工廠類, javax內部類
Context.INITIAL_CONTEXT_FACTORY=LdapCtxFactory.class.getName()
# 用戶名
Context.SECURITY_PRINCIPAL
# 口令
Context.SECURITY_CREDENTIALS
# SSL Socket工廠類
JndiPropertyConstants.JNDI_FACTORY_SOCKET
# ldaps地址
Context.PROVIDER_URL

其他參數都是可以不傳,因為內部有相應的判斷,可以省去部分的配置,如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// LdapCtx.java L2723-2742
if (envprops != null) {
user = (String)envprops.get(Context.SECURITY_PRINCIPAL);
passwd = envprops.get(Context.SECURITY_CREDENTIALS);
ver = (String)envprops.get(VERSION);
// 這里的useSsl全局變量是在前面就已經判斷過了,先判斷鏈接,也就是{@code Context.PROVIDER_URL}的scheme是什么,如果是ldaps,就會使用ssl;
// 還存在冗余判斷,當端口號是636時,默認也使用SSL
secProtocol =
useSsl ? "ssl" : (String)envprops.get(Context.SECURITY_PROTOCOL);
socketFactory = (String)envprops.get(SOCKET_FACTORY);
authMechanism =
(String)envprops.get(Context.SECURITY_AUTHENTICATION);
usePool = "true".equalsIgnoreCase((String)envprops.get(ENABLE_POOL));
}

// 當{@code JndiPropertyConstants.JNDI_FACTORY_SOCKET}沒有配置,且使用SSL時,使用默認Sun的SSL
if (socketFactory == null) {
socketFactory =
"ssl".equals(secProtocol) ? DEFAULT_SSL_FACTORY : null;
}

// 身份認證方式, 通過判斷是否有用戶名來確定
if (authMechanism == null) {
authMechanism = (user == null) ? "none" : "simple";
}

在創建SSLSocket時,是在Connection.java中,方法簽名如下:

1
2
com.sun.jndi.ldap.Connection#createSocket(String host, int port, String socketFactory,
int connectTimeout)

不能通過OOP的方式建立SSLSocket,因為會通過反射的方式創建SSL

1
2
3
4
5
6
7
8
// Connection.java L273-L278
@SuppressWarnings("unchecked")
// 這里的socketFactory就是我們自定義的socket工廠類
Class<? extends SocketFactory> socketFactoryClass =
(Class<? extends SocketFactory>)Obj.helper.loadClass(socketFactory);
// 通過反射的方式獲取默認的SSL引擎
Method getDefault = socketFactoryClass.getMethod("getDefault", new Class<?>[]{});
SocketFactory factory = (SocketFactory) getDefault.invoke(null, new Object[]{});

SSLSocketFactory內會創建默認的SSLSocket,除非我們指定SSLSocketFactory

1
2
3
// SSLSocketFactory L95
String clsName = getSecurityProperty("ssl.SocketFactory.provider");
... 初始化操作

所以我們在LdapsNoVerifySSLSocketFactory里面通過靜態代碼塊初始化配置了需要加載的類

另一種實現

所以這里引出另一種實現方式,可以減少代碼量。但是耦合度較高,那就是在JNDI初始化前,初始化SSLContext,並設置為默認

注:還是需要NoVerificationTrustManager.class(定義在了LdapsNoVerifySSLSocketFactory內部)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.security.SecureRandom;
import java.util.Hashtable;

import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;

import com.sun.jndi.ldap.LdapCtxFactory;

import cn.com.LdapsNoVerifySSLSocketFactory.NoVerificationTrustManager;

public class LdapsJNDIV2Test {

public static void main(String[] args) throws Exception{
NoVerificationTrustManager noVerificationTrustManager = new NoVerificationTrustManager();
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{noVerificationTrustManager}, new SecureRandom());
SSLContext.setDefault(sslContext);
Hashtable<String, String> env = new Hashtable<>();
String ldapURL = "ldaps://10.20.61.26:636";
String adminName = "CN=Administrator,CN=Users,DC=aaa,DC=com";
String adminPassword = "11111111";
env.put(Context.INITIAL_CONTEXT_FACTORY, LdapCtxFactory.class.getName());
env.put(Context.SECURITY_PRINCIPAL, adminName);
env.put(Context.SECURITY_CREDENTIALS, adminPassword);

try {env.put(Context.PROVIDER_URL, ldapURL);
LdapContext ctx = new InitialLdapContext(env, null);
System.out.println(ctx.getEnvironment());
} catch (NamingException e) {
e.printStackTrace();
}
}

}

兩種方式都可,選擇適合自己的就可以啦!🔚

[Bug Fix] Spring Boot Fat Jar 運行異常

拋出問題如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
javax.naming.CommunicationException: 10.20.70.72:636 [Root exception is java.net.SocketException: java.lang.ClassNotFoundException: io.gsealy.LdapsNoVerifySSLSocketFactory]
at java.naming/com.sun.jndi.ldap.Connection.<init>(Connection.java:237)
at java.naming/com.sun.jndi.ldap.LdapClient.<init>(LdapClient.java:137)
at java.naming/com.sun.jndi.ldap.LdapClient.getInstance(LdapClient.java:1616)
at java.naming/com.sun.jndi.ldap.LdapCtx.connect(LdapCtx.java:2752)
at java.naming/com.sun.jndi.ldap.LdapCtx.<init>(LdapCtx.java:320)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getUsingURL(LdapCtxFactory.java:192)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getUsingURLs(LdapCtxFactory.java:210)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getLdapCtxInstance(LdapCtxFactory.java:153)
at java.naming/com.sun.jndi.ldap.LdapCtxFactory.getInitialContext(LdapCtxFactory.java:83)
at java.naming/javax.naming.spi.NamingManager.getInitialContext(NamingManager.java:730)
at java.naming/javax.naming.InitialContext.getDefaultInitCtx(InitialContext.java:305)
at java.naming/javax.naming.InitialContext.init(InitialContext.java:236)
at java.naming/javax.naming.ldap.InitialLdapContext.<init>(InitialLdapContext.java:154)
at io.gsealy.Test.main(Test.java:39)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:47)
at org.springframework.boot.loader.Launcher.launch(Launcher.java:86)
at org.springframework.boot.loader.Launcher.launch(Launcher.java:50)
at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:51)
Caused by: java.net.SocketException: java.lang.ClassNotFoundException: io.gsealy.LdapsNoVerifySSLSocketFactory
at java.base/javax.net.ssl.DefaultSSLSocketFactory.throwException(SSLSocketFactory.java:263)
at java.base/javax.net.ssl.DefaultSSLSocketFactory.createSocket(SSLSocketFactory.java:277)
at java.naming/com.sun.jndi.ldap.Connection.createSocket(Connection.java:306)
at java.naming/com.sun.jndi.ldap.Connection.<init>(Connection.java:216)
... 21 more
Caused by: java.lang.ClassNotFoundException: io.gsealy.LdapsNoVerifySSLSocketFactory
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:582)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
at java.base/javax.net.ssl.SSLSocketFactory.getDefault(SSLSocketFactory.java:105)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:566)
at java.naming/com.sun.jndi.ldap.Connection.createSocket(Connection.java:278)
... 22 more

首先見到ClassNotFoundException就在想是不是因為類沒有打進去,排查后,這種情況不存在。又試了直接打包,運行正常。本以為是Spring Boot在打包Fat Jar時候的鍋,因為其特殊的打包方式,改變了正常包位置,比如說我們這里面的io.gsealy.LdapsNoVerifySSLSocketFactory類,其實是放在BOOT-INF/classes/目錄下,包名也就改成了BOOT-INF.classes.io.gsealy.LdapsNoVerifySSLSocketFactory,此時我就認為是Spring Boot的鍋了。

上面是完整的異常堆棧信息,具體關注這個地方:

1
2
at java.base/javax.net.ssl.DefaultSSLSocketFactory.createSocket(SSLSocketFactory.java:277)
at java.naming/com.sun.jndi.ldap.Connection.createSocket(Connection.java:306)

因為ClassLoader的不同,JNDI在反射創建SSLSocketFactory時,因為安全檢查的問題,無法通過反射調用方法。

1
2
3
4
5
// Connection.java L273-L278
Class<? extends SocketFactory> socketFactoryClass =
(Class<? extends SocketFactory>)Obj.helper.loadClass(socketFactory);
Method getDefault = socketFactoryClass.getMethod("getDefault", new Class<?>[]{});
SocketFactory factory = (SocketFactory) getDefault.invoke(null, new Object[]{});

在上面的代碼中。會調用getDefault()方法。因為getDefault()是一個靜態方法,方法簽名如下:

1
public static SocketFactory getDefault() {}

不是重載方法,所以最開始繼承SSLSocketFactory的時候,沒有修改這個方法實現,他還是會去調用SSLSocketFactorygetDefault(),也就是默認實現。默認實現是不能略過客戶端證書驗證的。所以會報錯。

重新添加getDefault()方法即可,就可以刪除靜態代碼塊中的參數綁定了,原來的連接代碼也要恢復為正常的,不需要使用另一種實現中說的實現。

修改好的文件地址:Gist Link


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM