【51CTO.com快译】在安全实践领域,TLS身份验证作为一种技术手段,通常能够保证任何用户通过证书,浏览到真实、安全的Web应用。而在此基础上发展而来的双向TLS(Two-Way TLS),则可以仅允许部分用户访问或调用目标应用。
下面,我将以示例的形式,并配合自动化脚本,依次向您展示:搭建服务器,向服务器发送未加密的hello消息,以HTTPS的方式在服务器上启用单向TLS,要求客户端通过双向TLS来标识自己,基于可信的CA(证书颁发机构)实现双向TLS,以及对HTTP客户端进行相关测试。
基本定义
- 身份标识(Identity):一对私钥与公钥,通常被存放在信任存储库中。
- 信任存储库(TrustStore):库中包含了一个或多个可信证书(也称为公钥)的列表。
- 单向认证(也称为单向tls、单向ssl):客户端在验证对方证书时用到的https连接。
- 双向认证(也称为双向tls、双向 ssl、双向认证):客户端与对方相互验证证书时的https 连接。
实用链接
通过如下的统一参考页,我向社区里正在使用Apache http、Java、Kotlin、以及Scala等开发人员,提供了包含40多个http客户端的配置示例。
在处理http请求的过程中,它们可能会导致应用在初始构建时,需要花费一定的时间来下载大量依赖项。因此,我也通过GitHub - SSLContext Kickstart(https://github.com/Hakky54/sslcontext-kickstart)来简化客户端的配置。由于每一个http客户端都可能需要不同的ssl对象来启用ssl,因此代码库需要提供基本的ssl配置。
启动服务器
首先,我们需要做好如下准备:
- Java 11
- Maven 3.5.0
- Eclipse、Intellij IDEA(或任何其他文本编辑器,如 VIM)
- 一个终端
- 从https://github.com/Hakky54/mutual-tls处克隆项目
如果您想在不安装任何软件的情况下,立即开始体验该项目,请点击链接,并通过在线开发环境的方式打开此项目。
由于该项目已经包含了一个maven包装器(wrapper),因此您可以在无需额外安装的情况下,运行该项目。同时,下面将涉及到的各种包含了maven包装器的命令,都已被默认包含在mvn命令中。
如果您想使用Java 8来运行该项目,则可以使用git命令:git checkout tags/java-8-compatible,来运行一个兼容的版本。有关参数的具体设置细节,请参见链接--https://github.com/Hakky54/mutual-tls-ssl/tree/java-8-compatible。
为了启动服务端,您可以在服务端的项目中运行App类的main方法,或者在终端的根目录下运行命令:cd server/ && mvn spring-boot:run,以及使用maven包装器:cd server-with-spring-boot/ && ./../mvnw spring-boot:run。
向服务器发送未加密的hello
由于当前运行在默认端口8080上的服务器端是未经加密的,因此您可以在终端中使用以下curl命令:curl -i -XGET http://localhost:8080/api/hello,来调用hello:
其响应内容如下(纯文本):
- HTTP/1.1 200
- Content-Type: text/plain;charset=UTF-8
- Content-Length: 5
- Date: Sun, 11 Nov 2018 14:21:50 GMT
- Hello
您还可以使用客户端目录中所提供的客户端应用,去调用服务器。由于客户端依赖于本项目的其他组件,所以您需要先在根目录下运行mvn install或./mvnw install。
此处的客户端是基于Cucumber的集成测试。您可以通过从IDE处运行ClientRunnerIT类、或从根目录中的终端运行:cd client/ && mvn exec:java、亦或使用maven的包装器命令:cd client/ && ./../mvnw exec:java,来启动之。您可以在客户端项目的测试资源中,通过Hello.feature文件,来获悉集成测试的具体步骤。
为了同时运行服务器和客户端中的方法,您可以在根目录中使用命令:mvn clean verify,或使用maven的包装器:./mvnw clean verify。如果服务端与客户端同处一台服务器,那么客户端会默认向localhost发送请求;如果它们在不同的主机上运行,您需要为客户端提供带有VM参数:-Durl=http://[HOST]:[PORT]的定制化的url。
在服务器上启用 HTTPS(即单向的TLS)
下面,我们来讨论如何通过启用TLS,来保护服务器端。您可以通过将如下所需的属性(YAML),添加到名为application.yml的应用属性文件中来实现:
- server:
- port: 8443
- ssl:
- enabled: true
在此,您可能会对为何将端口设置为 8443表示疑惑。其原因在于:带有https的tomcat服务的约定端口为8443,而对于http则是8080。虽然我们可以使用端口8080进行https连接,但这并不是一种推荐的做法。具体有关端口约定的详细信息,请参阅维基百科的链接。
您可以通过重启服务器,来生效那些对于应用的更改。当然,您也可能会收到异常信息:IllegalArgumentException: Resource location must not be null。该消息的产生,是因为服务器需要带有服务器证书的密钥库,以确保与外界的安全连接。如果您提供的VM参数为:Djavax.net.debug=SSL,keymanager,trustmanager,ssl:handshake,那么服务器可以为您提供更多的信息。
显然,为了解决此问题,您需要创建一个带有服务器公钥和私钥的密钥库。其中的公钥可与用户共享,以便加密彼此之间的通信;而服务器的私钥则可用来解密。值得注意的是,我们绝对不可以共享服务器的私钥,以避免被其他人用来破解截获到的通信,进而获悉被加密的通信内容。
因此,若要创建带有公钥和私钥的密钥库,请在终端中执行以下命令(纯文本):
- keytool -v -genkeypair -dname "CN=Hakan,OU=Amsterdam,O=Thunderberry,C=NL" -keystore shared-server-resources/src/main/resources/identity.jks -storepass secret -keypass secret -keyalg RSA -keysize 2048 -alias server -validity 3650 -deststoretype pkcs12 -ext KeyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement -ext ExtendedKeyUsage=serverAuth,clientAuth -ext SubjectAlternativeName:c=DNS:localhost,DNS:raspberrypi.local,IP:12 0.1
为了告知服务器密钥库的位置,以及具体的密码,请将如下YAML内容粘贴到您的application.yml文件中:
- server:
- port: 8443
- ssl:
- enabled: true
- key-store: classpath:identity.jks
- key-password: secret
- key-store-password: secret
至此,您已成功启用了服务器和客户端之间的TLS加密连接。您可以尝试着使用curl命令:curl -i --insecure -v -XGET https://localhost:8443/api/hello,去调用服务器。
当您在ClientRunnerIT类中运行客户端时,您可能会看到一条错误消息:java.net.ConnectException: Connection refused (Connection refused)。从字面上看,它是指客户端试图向服务器建立连接,可以被拒绝了。其深层原因是:客户端试图使用的是端口8080,而服务器只在端口8443上处于活跃状态。因此,您需要进行如下修改,并将其应用到Constants类中,即从:
- private static final String DEFAULT_SERVER_URL = "http://localhost:8080";
改为:
- private static final String DEFAULT_SERVER_URL = "https://localhost:8443";
在完成修改之后,让我们再次运行客户端。您会看到另一条消息:“javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target”。这意味着客户端希望通过HTTPS进行通信,但是在它握手的过程中,收到了无法识别的服务器证书。可见,您还需要创建一个包含了各种受信任证书的信任库,以方便客户端在SSL握手过程中,将收到的证书与其信任库里的证书内容进行比较。如果相匹配的话,则可以继续SSL的握手过程。当然,在创建信任库之前,您需要事先获得服务器的证书。
导出服务器的证书
您可以使用如下命令,导出服务器的证书:
- keytool -v -exportcert -file shared-server-resources/src/main/resources/server.cer -alias server -keystore shared-server-resources/src/main/resources/identity.jks -storepass secret -rfc
接着,您可以为客户端创建一个信任库,并使用如下命令导入服务器的证书:
- keytool -v -importcert -file shared-server-resources/src/main/resources/server.cer -alias server -keystore client/src/test/resources/truststore.jks -storepass secret -noprompt
为了让客户端知晓信任库的存在,您还需要告知其信任库的正确位置、密码、以及身份验证已启用。您可以在客户端的application.yml文件中,提供如下属性:
- client:
- ssl:
- one-way-authentication-enabled: true
- two-way-authentication-enabled: false
- trust-store: truststore.jks
- trust-store-password: secret
对客户端进行身份验证(双向TLS)
接下来,服务器端需要验证客户端的身份,以判断其是否可信。其实现方式为:通过client-auth属性放入服务器的application.yml中,以告知服务器去验证客户端。
- server:
- port: 8443
- ssl:
- enabled: true
- key-store: classpath:identity.jks
- key-password: secret
- key-store-password: secret
- client-auth: need
当然,如果您直接运行它,则会因为客户端根本没有证书,而产生错误消息:javax.net.ssl.SSLHandshakeException: Received fatal alert: bad_certificate(无效的证书信息)。因此,我们需要通过如下命令,来创建证书:
- keytool -v -genkeypair -dname "CN=Suleyman,OU=Altindag,O=Altindag,C=NL" -keystore client/src/test/resources/identity.jks -storepass secret -keypass secret -keyalg RSA -keysize 2048 -别名客户端 -validity 3650 -deststoretype pkcs12 -ext KeyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement -ext ExtendedKeyUsage=serverAuth,clientAuth
同时,您还需要为服务器创建一个信任库。不过,在创建信任库之前,您需要通过如下命令获取客户端的证书:
- keytool -v -exportcert -file client/src/test/resources/client.cer -alias client -keystore client/src/test/resources/identity.jks -storepass secret -rfc
下一步便是使用客户端的证书,来创建服务器的信任库:
- keytool -v -importcert -file client/src/test/resources/client.cer -alias client -keystore shared-server-resources/src/main/resources/truststore.jks -storepass secret -noprompt
同样,为了让客户端获悉该密钥库的存在,您还需要告知其信任库的正确位置、密码、以及身份验证已启用。您可以在客户端的application.yml文件中,提供如下属性:
- client:
- ssl:
- one-way-authentication-enabled: false
- two-way-authentication-enabled: true
- key-store: identity.jks
- key-password: secret
- key-store-password: secret
- trust-store: truststore.jks
- trust-store-password: secret
对应地,为了让服务器知晓新创建的信任库,我们需要将当前属性替换为以下属性:
- server:
- port: 8443
- ssl:
- enabled: true
- key-store: classpath:identity.jks
- key-password: secret
- key-store-password: secret
- trust-store: classpath:truststore.jks
- trust-store-password: secret
- client-auth: need
至此,您已完成了双向TLS的安装。如果再次运行客户端,您将会发现客户端能够以安全的方式,从服务器端接收到hello消息了。
基于可信CA的双向TLS
有了前面的基础,我们便可以采用基于可信CA的双向(mutual)认证了。我们首先来看看它的优缺点:
优点
- 客户端不需要自行添加服务器的证书。
- 服务器不需要添加客户端的所有证书。
- 由于是由CA管控着证书的有效期,因此本地运维工作会大幅减少。
缺点
- 您无法细粒度地控制哪些客户端可以调用自己的应用,哪些不可以。任何客户端,只要持有CA颁发的证书,即可访问您的应用程序。
其具体实现步骤如下:
1. 创建CA
通常,您需要向某个已有的证书颁发机构,提供自己的证书以获取其签名。下面,我们将创建一个自己的CA,并用它去签发客户端和服务器的证书。
- keytool -v -genkeypair -dname "CN=Root-CA,OU=Certificate Authority,O=Thunderberry,C=NL" -keystore root-ca/identity.jks -storepass secret -keypass secret -keyalg RSA -keysize 2048 -alias root-ca -validity 3650 -deststoretype pkcs12 -ext KeyUsage=digitalSignature,keyCertSign -ext BasicConstraints=ca:true,PathLen:3
当然,您也可以使用存储库默认提供的那个,具体请参阅identity.jks。
2. 创建证书签名请求
为了签发证书,您需要通过如下命令,提供一个证书签名请求 (.csr) 文件。其中,服务器的证书签名请求为:
- keytool -v -genkeypair -dname "CN=Root-CA,OU=Certificate Authority,O=Thunderberry,C=NL" -keystore root-ca/identity.jks -storepass secret -keypass secret -keyalg RSA -keysize 2048 -alias root-ca -validity 3650 -deststoretype pkcs12 -ext KeyUsage=digitalSignature,keyCertSign -ext BasicConstraints=ca:true,PathLen:3
而客户端的证书签名请求为:
- keytool -v -certreq -file client/src/test/resources/client.csr -keystore client/src/test/resources/identity.jks -alias client -keypass secret -storepass secret -keyalg rsa
3. 使用证书签名请求签发证书
签发客户证书:
- keytool -v -gencert -infile client/src/test/resources/client.csr -outfile client/src/test/resources/client-signed.cer -keystore root-ca/identity.jks -storepass secret -alias root-ca -validity 3650 -ext KeyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement -ext ExtendedKeyUsage=serverAuth,clientAuth -rfc
签发服务器证书:
- keytool -v -gencert -infile shared-server-resources/src/main/resources/server.csr -outfile shared-server-resources/src/main/resources/server-signed.cer -keystore root-ca/identity.jks -storepass secret -alias root-ca -validity 3650 -ext KeyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement -ext ExtendedKeyUsage=serverAuth,clientAuth -ext SubjectAlternativeName:c=DNS:localhost,DNS:raspberrypi.local,IP:127.0.0.1 -rfc
4. 用已签名的证书替换未签名的证书
由于我们无法直接用密钥工具(keytool)去导入已签名的证书,因此我们需要将由CA签发的证书存储到identity.jks中。先导出CA证书:
- keytool -v -exportcert-文件root-ca / root-ca.pem -alias root-ca -keystore root-ca / identity.jks -storepass secret -rfc
然后是客户端:
- keytool -v -importcert -file root-ca/root-ca.pem -alias root-ca -keystore client/src/test/resources/identity.jks -storepass secret -noprompt
- keytool -v -importcert -file client/src/test/resources/client-signed.cer -alias client -keystore client/src/test/resources/identity.jks -storepass secret
- keytool -v -delete -alias root-ca -keystore client/src/test/resources/identity.jks -storepass secret
最后是服务器端:
- keytool -v -importcert -file root-ca/root-ca.pem -alias root-ca -keystore shared-server-resources/src/main/resources/identity.jks -storepass secret -noprompt
- keytool -v -importcert -file shared-server-resources/src/main/resources/server-signed.cer -alias server -keystore shared-server-resources/src/main/resources/identity.jks -storepass secret
- keytool -v -delete -alias root-ca -keystore shared-server-resources/src/main/resources/identity.jks -storepass secret
5. 设置仅信任CA
为了将客户端和服务器配置为仅信任某个CA,我们需要通过将CA证书导入客户端和服务器的信任库来实现。其中在客户端,我们可以使用如下操作命令:
- keytool -v -importcert -file root-ca/root-ca.pem -alias root-ca -keystore client/src/test/resources/truststore.jks -storepass secret -noprompt
在服务器端则为:
- keytool -v -importcert -file root-ca/root-ca.pem -alias root-ca -keystore shared-server-resources/src/main/resources/truststore.jks -storepass secret -noprompt
同时,由于信任库仍包含客户端和服务器的原有特定证书,因此我们需要将其删除。其中在客户端,我们可以使用如下操作命令:
- keytool -v -delete -alias server -keystore client/src/test/resources/truststore.jks -storepass secret
在服务器端则为:
- keytool -v -delete -alias client -keystore shared-server-resources/src/main/resources/truststore.jks -storepass secret
至此,如果您再次运行客户端,将能够顺利通过测试。客户端将会接收到来自服务器的hello消息,而且其中的证书是由该CA所颁发的。
带有TLS身份验证的自动化脚本
其实,您还可以使用该项目的脚本目录里的各种脚本,来自动化执行上述步骤。例如,对于单向认证而言,可以输入:./configure-one-way-authentication;而对于双向认证而言,则可以输入:./configure-two-way-authentication-by-trusting-each-other my-company-name;对于通过可信CA进行的双向身份验证,可以输入:./configure-two-way-authentication-by-trusting-root-ca my-company-name。
已测试的客户端
下面是已经通过测试的客户端列表。您可以在ClientConfig类中找到基于纯Java的http客户端配置。该服务目录包含了单个的http客户端请求示例。其中,基于Kotlin和Scala的http客户端配置是作为嵌套类被包含在内的。而且,所有客户端示例都使用的是在SSLConfig类中创建的相同的ssl基本配置。
Java
- Apache HttpClient -> Client configuration | Example request
- Apache HttpAsyncClient -> Client configuration | Example request
- Apache 5 HttpClient -> Client configuration | Example request
- Apache 5 HttpAsyncClient -> Client configuration | Example request
- JDK HttpClient -> Client Configuration | Example request
- Old JDK HttpClient -> Client Configuration & Example request
- Netty Reactor -> Client Configuration | Example request
- Jetty Reactive HttpClient -> Client Configuration | Example request
- Spring RestTemplate -> Client Configuration | Example request
- Spring WebFlux WebClient Netty -> Client Configuration | Example request
- Spring WebFlux WebClient Jetty -> Client Configuration | Example request
- OkHttp -> Client Configuration | Example request
- Jersey Client -> Client Configuration | Example request
- Old Jersey Client -> Client Configuration | Example request
- Google HttpClient -> Client Configuration | Example request
- Unirest -> Client Configuration | Example request
- Retrofit -> Client Configuration | Example request
- Async Http Client -> Client Configuration | Example request
- Feign -> Client Configuration | Example request
- Methanol -> Client Configuration | Example request
- Vertx Webclient -> Client Configuration & Example request
- RPC -> Client/Server Configuration & Example request
- ElasticSearch -> RestHighLevelClient Configuration & example request
Kotlin
- Fuel -> Client Configuration & Example request
- Http4k with Apache 4 -> Client Configuration | Example request
- Http4k with Async Apache 4 -> Client Configuration | Example request
- Http4k with Apache 5 -> Client Configuration | Example request
- Http4k with Async Apache 5 -> Client Configuration | Example request
- Http4k with Java Net -> Client Configuration | Example request
- Http4k with Jetty -> Client Configuration | Example request
- Http4k with OkHttp -> Client Configuration | Example request
- Kohttp -> Client Configuration & Example request
- Ktor with Android engine -> Client Configuration | Example request
- Ktor with Apache engine -> Client Configuration | Example request
- Ktor with CIO (Coroutine-based I/O) engine -> Client Configuration | Example request
- Ktor with Okhttp engine -> Client Configuration | Example request
Scala
- Twitter Finagle -> Client Configuration | Example request
- Twitter Finagle Featherbed -> Client Configuration & Example request
- Akka Http Client -> Client Configuration | Example request
- Dispatch Reboot -> Client Configuration & Example request
- ScalaJ / Simplified Http Client -> Client Configuration & Example request
- Sttp -> Client Configuration & Example request
- Requests-Scala -> Client Configuration & Example request
- Http4s Blaze Client -> Client Configuration | Example request
- Http4s Java Net Client -> Client Configuration | Example request
原文标题:How to Easily Set Up Mutual TLS,作者:Hakan Altındağ
【51CTO译稿,合作站点转载请注明原文译者和出处为51CTO.com】