☰
Spring Boot自发布HTTPS服务:证书配置到双向认证实战
2026/10/18 10:27:17 网站建设 项目流程

1. 为什么非要用Spring Boot自己发布HTTPS服务

先聊几句动机。很多刚接触Spring Boot的开发者,第一反应是“HTTPS不都是Nginx或者网关层的事吗,业务代码里配这个干嘛”。生产环境里确实通常由负载均衡器或反向代理终结SSL证书,后端服务跑HTTP就行。但在本地联调、前后端分离的本地开发、内网直连、嵌入式设备、或者交付给客户的独立部署包这些场景里,你经常没有Nginx可用,或者说为了一个演示环境再搭一套代理太冗余。这时候如果Spring Boot自身能直接把HTTPS端口拉起来,整个交付物就是一个可执行Jar,省掉一大堆外部依赖。

我最初遇到这个需求是在做一个某跨平台系统的时候,客户要求内网环境不能依赖外部Web服务器,所有组件必须在一个Java进程里跑完。当时我问了一圈身边的人,很多人的回答是“用Nginx转发啊”,但客户的环境连Nginx都不一定允许装。被逼着研究了一阵子Spring Boot的内嵌容器对SSL的原生支持,发现其实Tomcat和Netty都做得挺完善,配置起来并不复杂,关键是要搞清楚证书格式、KeyStore类型、双向认证这些底层概念。这篇文章就把我从零到一的过程完整写出来,包括怎么生成证书、怎么配置、怎么主动调用验证,以及我在实际中踩过的坑。

这篇文章适合这些人看:用Spring Boot做独立服务部署的、需要在本地用HTTPS调试前后端联调的、以及要在Java生态内做服务间加密通信的。不需要你有深厚的密码学基础,只要Java环境和Spring Boot基本使用没问题,跟着做就能跑起来,遇到问题也知道往哪个方向排查。

2. HTTPS发布前的核心准备:证书与KeyStore选型

2.1 证书生成方案对比:自签名、内部CA、公网证书

在动手配置Spring Boot之前,得先把证书这件事理清楚。HTTPS的信任基础是证书链,服务端要把证书发给客户端,客户端验证这张证书是不是可信CA签发的。我们在本地或者内网环境里,通常没有公网CA签发的证书,所以就要面临选型问题。

方案适用场景客户端信任处理成本
自签名证书本地开发、临时演示客户端需导入信任或忽略校验0,几分钟搞定
内部CA签名内网多服务、多机器长期使用只需把内部CA根证书装到各机器信任库0,但需要维护一个CA
公网CA证书正式对外、浏览器访问无需额外处理需要域名和证书费用

我建议本地开发和内网联调直接用自签名就够了,原因很朴素:省事。自签名证书在Spring Boot里用JDK自带的keytool就能生成,不用装额外工具也不用买域名。但要注意一个问题,自签名证书生成的KeyStore默认是PKCS12还是JKS,这取决于JDK版本和你的指定方式。JDK 9以上默认就是PKCS12,而Spring Boot两种都支持,只是配置时要把类型写对。

如果你是为某个实验室或某公司内部的多套系统统一做HTTPS,那我更推荐搭一个内部CA。思路是先创建一个CA根证书,再用这个CA去签发各个服务的证书。这样每个客户端只需要信任同一个根证书,就能访问所有内部服务,而不需要为每台机器单独导入自签名证书。这种做法和“公司统一给员工配一张门禁卡,而不是每扇门各配一把锁”是一个道理。

2.2 KeyTool生成自签名证书的完整命令与参数解读

生成证书最常用的工具是JDK自带的keytool。命令行看起来长,实际上拆开看每一个参数都很好理解。我下面给出一个可以直接抄走的命令:

keytool -genkeypair -alias https-server -keyalg RSA -keysize 2048 \ -validity 3650 -keystore server.p12 -storetype PKCS12 \ -storepass changeit -dname "CN=localhost, OU=Dev, O=Example, L=City, C=CN"

参数逐条解释:

  • -genkeypair:生成一对密钥,包含公钥和私钥。这里生成的是密钥对,而不仅仅是证书。
  • -alias https-server:给这个密钥条目起个名字,同一个KeyStore里可以放多对密钥,Alias就是它们的唯一标识。
  • -keyalg RSA:密钥算法。RSA是通用的,ECDSA也可以,但RSA兼容性更好,尤其考虑到有些老版本的客户端可能不支持ECDSA。
  • -keysize 2048:密钥长度。2048是目前的安全基线,低于1024已经不安全了,4096更安全但握手时性能会稍差。内网用2048完全够。
  • -validity 3650:有效期天数,3650就是大约10年。自签名证书如果过期了,部署环境又没配自动续期,找问题的时候会特别痛苦。
  • -keystore server.p12:输出文件名为server.p12。
  • -storetype PKCS12:KeyStore类型,统一用PKCS12,因为它是标准格式,JKS是私有格式。
  • -storepass changeit:KeyStore密码,实际项目中换一个强度足够且自己能记住的密码。
  • -dname:证书的主题信息,CN=localhost表示这个证书是为localhost这个域名签发的。这里要特别注意,如果客户端访问时用的地址和CN不一致,浏览器或Java客户端就会报域名不匹配的错。

生成之后可以用下面命令验证一下:

keytool -list -v -keystore server.p12 -storepass changeit

这条命令会打印出证书的详细信息,包括算法、指纹、有效期、CN等,配置完Spring Boot后如果出现问题,回来查一下证书本身是最快的定位方式。

2.3 一个容易忽略的坑:CN和访问域名不一致

自签名证书的域名匹配问题经常被人忽略。我在第一次做联调的时候,生成的证书CN写的是一台测试机的IP地址,然后我用域名去访问,结果客户端直接握手失败,日志里明确写了“No subject alternative names present”或者“Certificate for doesn't match any of the subject alternative names”这类错误。

Java客户端的验证比浏览器还严格,它不仅要检查CN,还会检查Subject Alternative Name(SAN),也就是证书里的主题备用名称。keytool在较新的JDK里可以用-ext参数直接加SAN:

keytool -genkeypair -alias https-server -keyalg RSA -keysize 2048 \ -validity 3650 -keystore server.p12 -storetype PKCS12 \ -storepass changeit -dname "CN=localhost, OU=Dev, O=Example, L=City, C=CN" \ -ext "SAN=dns:localhost,ip:127.0.0.1"

这样证书里面就同时包含了localhost和127.0.0.1的SAN条目,不管客户端用哪个地址访问都能匹配上。如果你的机器在内网还有其他主机名或IP,一并加进去,省得后面再重新生成。

3. Spring Boot主配置里的HTTPS参数详解

3.1 application.yml中SSL配置的完整写法

做完证书这步,Spring Boot这边的配置就相对简单了。以application.yml为例,核心配置如下:

server: port: 8443 ssl: enabled: true key-store: classpath:server.p12 key-store-type: PKCS12 key-store-password: changeit key-alias: https-server
  • server.port:HTTPS服务监听的端口。习惯上用8443而不是443,因为443通常需要root权限才能监听,本地开发用8443省心。
  • ssl.enabled=true:显式开启SSL,虽然Spring Boot在检测到key-store配置后会自动开启,但写出来更明确。
  • key-store:KeyStore文件的位置。classpath:server.p12表示把证书文件放到src/main/resources目录下,打包进Jar里。如果你的证书不想打进包,可以写成file:/path/to/server.p12。
  • key-store-type:对应生成证书时的-storetype,PKCS12。
  • key-store-password:KeyStore密码。
  • key-alias:KeyStore中密钥条目的别名,要和keytool生成时的-alias保持一致。

提示:以上配置我只是继续沿用自签名示例中的changeit作为演示密码,实际项目中绝不要用这种默认密码。不过要注意,KeyStore密码和证书本身是两个概念,很多人混淆,其实KeyStore可以理解为装证书的保险柜,密码是保险柜的锁,证书本身没有单独的密码概念。

启动Spring Boot后,控制台日志里如果出现了Tomcat started on port 8443 (https) with context path,说明HTTPS服务已经正常起来。用浏览器访问https://localhost:8443时会提示证书不受信任,这是因为浏览器不认自签名证书,正常现象,点“高级-继续访问”即可,后面讲到Java客户端调用时会说明怎么处理信任问题。

3.2 HTTPS与HTTP同时支持的配置思路

有些场景下希望同一个应用既能走HTTPS也能走HTTP,比如部分内部健康检查接口想走HTTP,业务接口强制走HTTPS。Spring Boot原生配置里,server.port和server.ssl是绑定在一起的,设置SSL后这个端口就只接受HTTPS请求,没法原生地同时监听两个协议。

解决思路是增加一个额外的Tomcat Connector,让它监听HTTP端口,并自动把HTTP请求重定向到HTTPS。简单来说,在配置类中手动注册一个TomcatServletWebServerFactory,给它添加一个默认端口为8080的Connector,同时设置redirectPort指向HTTPS端口。这里不贴完整代码了,因为实际中用到的场景不算多,而且一旦引入额外Connector,会遇到一些和Spring Boot内嵌容器生命周期相关的细微问题,排查起来比较费劲。我个人不太推荐这个方案,宁可前面放一个轻量代理把HTTP流量转成HTTPS,代码也简单得多。

3.3 指定协议版本与Cipher套件

如果对安全性要求比较高,可以进一步限定TLS版本和加密套件。配置方式是在SSL配置下加两项:

server: ssl: enabled: true protocol: TLS enabled-protocols: TLSv1.3,TLSv1.2 ciphers: TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256

protocol: TLS是让容器使用TLS协议族,enabled-protocols是允许的具体版本。TLSv1.3是最新的,TLSv1.2是目前兼容性最好的老版本,两个都开基本覆盖了所有主流客户端。ciphers更复杂一些,如果你不确定自己该选哪些,我建议不要显式配置这行,让JVM自动选择,否则很容易出现“客户端算法不匹配导致握手失败”的问题。

做一个小的原则总结:默认配置能跑通就尽量用默认,安全加固要有针对性,不要为了显摆配置项把整个链路搞挂。

4. 编写客户端代码主动调用HTTPS服务

4.1 使用RestTemplate调用HTTPS服务的两种思路

服务端起来之后,怎么用Java代码主动去调用这个HTTPS服务,就是下一个核心问题。这里至少有两种主流选择:RestTemplate和WebClient。我以RestTemplate为例,因为它在传统Spring MVC项目里仍然是主流,特别是维护老项目的人,基本都还在用它。

调用HTTPS服务最大的障碍仍然在证书信任。如果服务端用的是公网CA签发的证书,那RestTemplate直接就能调通,因为JVM的默认信任库信任那些根CA。但如果服务端是自签名证书,默认情况下RestTemplate会直接抛SSLHandshakeException,表现就是连接被拒绝握手失败。

解决办法分两类:

第一种是“全局信任所有证书”,适合测试环境。实现方式是创建一个忽略所有证书校验的SSLContext,替换到RestTemplate的请求工厂里。这种方式最省事,但安全性最低,绝不能带到生产。

第二种是“定向信任自签名证书”,这里又分两个层次:

  • 把自签名证书导入到JVM的cacerts信任库,导入之后全JVM范围内该证书都会受信,影响面比较大,而且需要修改JVM环境的文件,不推荐放进代码仓库。
  • 代码里动态加载一个只包含目标证书的信任库,构建SSLContext,这样做影响面最小,只影响我们创建的RestTemplate,推荐这种方式。

下面我给出第二种方式的具体做法。

4.2 基于自定义TrustStore的RestTemplate配置

先把服务端的证书导出来,命令如下:

keytool -exportcert -alias https-server -keystore server.p12 \ -storepass changeit -file server.crt

这样会得到server.crt这个公钥证书文件。接下来在客户端项目的src/main/resources下新建一个专门用于连接该服务的信任库,比如client-truststore.p12,然后把证书导入:

keytool -importcert -alias https-server -file server.crt \ -keystore client-truststore.p12 -storetype PKCS12 \ -storepass changeit -noprompt

然后客户端的Java代码可以这样写:

@Configuration public class HttpsClientConfig { @Value("${client.trust-store}") private String trustStorePath; @Value("${client.trust-store-password}") private String trustStorePassword; @Bean public RestTemplate httpsRestTemplate() throws Exception { Resource resource = new ClassPathResource("client-truststore.p12"); InputStream inputStream = resource.getInputStream(); KeyStore keyStore = KeyStore.getInstance("PKCS12"); keyStore.load(inputStream, trustStorePassword.toCharArray()); TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init(keyStore); SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, tmf.getTrustManagers(), null); HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); HttpURLConnectionFactory connectionFactory = new HttpURLConnectionFactory() { @Override public HttpURLConnection createConnection(URL url) throws IOException { HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); conn.setSSLSocketFactory(sslContext.getSocketFactory()); conn.setHostnameVerifier((hostname, session) -> true); return conn; } }; SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); requestFactory.setHttpURLConnectionFactory(connectionFactory); return new RestTemplate(requestFactory); } }

逐段解释这段代码背后在干什么:

  • keyStore.load(inputStream, password):把client-truststore.p12文件中的证书加载进内存。TrustManagerFactory使用这套证书去构建信任管理器,以后SSLContext做握手时,只会信任这个信任库里的证书。
  • sslContext.init(null, tmf.getTrustManagers(), null):第一参数是KeyManager[],客户端场景不需要提供私钥,所以传null。第二参数传入的就是TrustManager。第三参数是SecureRandom,传null表示使用默认随机源。
  • HttpsURLConnection.setDefaultHostnameVerifier:这一行是为了跳过主机名验证。因为自签名证书的CN即使写了SAN,也可能和你访问的IP不完全一致,如果不跳过这个验证,会抛SSLPeerUnhandledException或HostnameVerifier相关的异常。这段代码作为全局默认会影响整个JVM,生产环境要改为在connectionFactory内部对每个连接设置一个独立的HostnameVerifier,只在当前连接上生效。

整体思路可以这样类比:以前微信加好友需要先通过验证,验证通过之后才能聊天。自定义TrustStore就是你先手动把对方加为好友,以后通信直接走;而全局信任所有证书,相当于来了谁都自动通过,方便是方便,但也很危险。

4.3 使用Apache HttpClient作为底层实现

如果你用的是RestTemplate且配置了HttpClient作为请求底层,逻辑类似,但代码会更简单,因为HttpClient本身允许配置自定义的SSLContext和HostnameVerifier,整体代码更清爽:

SSLContext sslContext = SSLContextBuilder.create() .loadTrustMaterial(new File("client-truststore.p12"), password.toCharArray()) .build(); CloseableHttpClient httpClient = HttpClients.custom() .setSSLContext(sslContext) .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) .build(); HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); RestTemplate restTemplate = new RestTemplate(requestFactory);

这两种方式选哪个,取决于项目里现有的HTTP客户端依赖。如果项目中已经有Apache HttpClient,直接用后者;如果不想引入额外依赖,就用JDK自带的HttpURLConnection方案,也就是前面4.2的代码。别两套混着用,调试时会让人怀疑人生。

4.4 调用示例与响应处理

配置好RestTemplate之后,调用HTTPS接口和普通HTTP接口没啥差别:

@RestController public class DemoController { private final RestTemplate httpsRestTemplate; public DemoController(RestTemplate httpsRestTemplate) { this.httpsRestTemplate = httpsRestTemplate; } @GetMapping("/call") public String callRemote() { String url = "https://localhost:8443/api/hello"; String response = httpsRestTemplate.getForObject(url, String.class); return "remote response: " + response; } }

如果服务端接口需要带请求头、传JSON体,那就用ResponseEntity加HttpHeaders配合使用,这部分和HTTP调用完全一样,唯一变了的就是底层的SSL。我经常跟别人说,HTTPS调用的复杂度不在于业务代码,而全在“信任”这两个字上。

5. 双向HTTPS认证的配置与调用

5.1 什么是双向认证,什么场景需要

单向前面的方案叫单向认证,也就是客户端验证服务端是不是可信的,服务端不管客户端是谁。双向认证则是服务端也要求客户端出示一张证书,双方互相验证身份。典型的应用场景包括:服务间API调用、设备接入平台、微服务间的通信。

和单向认证相比,双向认证多了一个Client Certificate的概念。服务端配置中需要指定client-auth模式,同时维护一个信任库来验证客户端证书。

5.2 服务端开启双向认证的配置

服务端生成证书的步骤和前面一样,但还需要额外生成一张客户端证书,并把客户端证书导入服务端的信任库。生成格式和导入方式都可以沿用keytool。

服务端的SSL配置变成:

server: port: 8443 ssl: enabled: true key-store: classpath:server.p12 key-store-type: PKCS12 key-store-password: changeit key-alias: https-server trust-store: classpath:server-truststore.p12 trust-store-type: PKCS12 trust-store-password: changeit client-auth: want
  • trust-store:服务端用来验证客户端证书的信任库。提前把客户端证书导入到这里面。
  • client-auth:有三个值可选。none表示不要客户端证书;want表示“最好给我,但不给我也能继续”;need表示“必须给我,否则拒绝握手”。想强制双向认证,就直接用need,不要用want,因为用want的时候有些客户端不给证书照样能连上来,后面写业务逻辑时你还要额外判断,容易漏。

want和need的区别,有点像一个小区门卫:want是“你有门禁卡最好,没卡登记一下也能进”,need是“没卡绝对进不来”。线上做严格管控的接口,选need才靠谱。

5.3 客户端侧携带证书的代码配置

客户端这里需要在原来TrustStore配置的基础上,增加KeyManager,把自己的客户端证书和私钥加载进去。相关代码如下:

KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); clientKeyStore.load(new ClassPathResource("client.p12").getInputStream(), clientPassword.toCharArray()); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); kmf.init(clientKeyStore, clientPassword.toCharArray()); sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

一句话总结就是:客户端证书放KeyManager里,服务端证书放TrustManager里,双方在SSL握手时各拿各的给对方验证。

这里面非常容易踩的坑是:生成客户端证书时,私钥没有正确存储在PKCS12里,或者密码不对,导致KeyManagerFactory.init阶段就抛异常。如果服务端日志一直提示bad_certificate,先检查客户端的密钥库密码和别名是否都对应得上,再用keytool的list命令确认里面确实包含了私钥条目。

6. 实战中的踩坑记录与排查思路

6.1 握手失败时先看这一条异常链

HTTPS的报错信息有时候非常绕,容易被各种上层异常掩盖。比如RestTemplate调用时报SSLException: Received fatal alert: unknown_ca,实际原因就是客户端不信任服务端证书。再比如报SSLHandshakeException: PKIX path building failed,也基本可以断定是信任链问题。

我总结了一套排查顺序:

  1. 先确认服务端端口起来没起来,用curl -k https://localhost:8443试试,-k意思是忽略证书校验,能通就说明服务本身没问题。
  2. 再确认客户端是否信任服务端证书,最常见的还是没导入或导错了信任库。
  3. 确认访问域名和证书CN/SAN是否匹配,不匹配就加SAN或者访问时用证书上的域名。
  4. 最后才考虑协议版本和Cipher套件问题,因为默认值通常很兼容。

这套流程多走几遍,绝大多数HTTPS报错都能定位。

6.2 一个实际案例:RestTemplate调用时PKIX path building failed的完整排查

有一次我在某跨平台系统联调时,服务端的HTTPS已经起来了,浏览器也能正常访问,但用RestTemplate一调就报PKIX path building failed。当时第一反应是证书没导入客户端的信任库,于是反复检查了client-truststore.p12里的证书,确认位置和代码路径都对,依然报错。

后来在日志里看到一行关键信息,CertPathBuilderException: Subject alternative names missing。也就是说信任链其实已经过了,但主机名验证失败。原因是访问时URL写的是https://192.168.1.10:8443,证书里却只有localhost的SAN,主机名校验过不去。

解决方法是把IP地址追加到证书的SAN里,重新生成证书后再导入信任库,问题立刻消失。这个案例说明,HTTPS报错不能只看第一层异常,完整看一下整条堆栈,往往问题在最后一两行。

6.3 证书文件在Jar包内外的处理区别

如果证书文件放在src/main/resources里,打包后它在Jar包内部,启动时用classpath:server.p12访问没问题。但如果你想在不重新打包的情况下替换证书,这种方案就很不方便,因为修改Jar包内的资源很麻烦。

另一个常见做法是把证书放在外部目录,配置改为:

server: ssl: key-store: file:/opt/app/config/server.p12

这样替换证书只需要替换磁盘文件,再重启服务即可。在真正部署时我强烈建议用外部文件方式,尤其是交付给运维去管的时候,他们能直接看到证书文件在哪里,也不会每次发版都要跟着打一遍包。

6.4 调试技巧:开启握手日志定位问题

如果上面的常规排查还找不到原因,可以打开JVM的SSL调试日志,这是最后的杀手锏。在启动参数里加:

-Djavax.net.debug=ssl:handshake

这个参数会输出完整的握手过程,包括客户端发来的Hello消息、证书链、是否匹配信任库等。日志会非常详细,看着像天书,但里面有几行非常关键,比如ServerHello、CertificateRequest、CertificateVerify等。一旦看到Remote host terminated the handshake这种提示,就可以顺藤摸瓜找到握手失败的真实原因。

注意:ssl:handshake日志在生产环境千万不要常开,它会产生大量日志并拖慢性能,只用来做临时排查。

7. 从开发到上线的几个建议

7.1 证书有效期管理与续期

自签名证书的有效期可以设很长,比如10年,但这不意味着你可以彻底忘掉它。一旦证书过期,服务能启动但握手一定失败,表现为所有客户端突然连不上。最好在项目里加一个定时任务,每年或每季度检查一次证书有效期,提前生成新证书并安排替换窗口。

7.2 不要把私钥打进代码仓库

server.p12里同时包含了私钥和公钥证书,它的安全性等同于生产密码。很多人为了方便直接把证书放在资源目录下和源码一起提交到代码仓库,这是非常危险的,任何一个拿到仓库权限的人都能解密出你的私钥。正确做法是把证书文件放到单独的配置目录,并且通过部署系统的私有配置管理或手工拷贝方式分发,代码仓库里只保留一个样例占位文件。

7.3 开启双向认证时的证书轮换问题

双向认证有个额外的维护成本:客户端证书过期后,如果没及时轮换,所有调用都会中断。我之前见过一个项目,客户端证书过期后,排查了半天才发现是证书问题,因为报错信息读起来像网络问题。所以双向认证环境里,一定要把所有端点的证书过期时间都记录在一个地方,提前例行检查,别等线上全挂了才想起来。

8. 我自己的最终体会

从生成证书到配置Spring Boot,再到客户端调用,整个过程做下来会发现,HTTPS其实不难,难点往往在证书和信任链这些和业务无关的底层细节上。我在第一次实践时花费最多时间的,不是写配置,而是排查各种“离奇”的握手失败,最后发现基本都是主机名不匹配、信任库选错或者证书格式没对齐。

如果你现在正准备在自己的项目里发布HTTPS服务,我建议按这个顺序走:先用浏览器验证服务端,确认端口和证书都没问题;再写一个最简单的Java客户端调用,把证书信任处理加上;通过之后再引入双向认证这类高级功能。每一步都验证到位,后面排查问题的面就小得多。这套流程我用了很久,至今仍然是最顺手的一套。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询