☰
SpringBoot + SSL证书实战:HTTP转HTTPS与WebSocket升级WSS全指南
2026/10/20 16:16:33 网站建设 项目流程

最近在做某消息推送服务时,被前端同事催着把 WebSocket 从 ws 换成 wss,同时整个服务还得从 HTTP 升级到 HTTPS。折腾了两天,踩完证书格式、端口跳转、浏览器拦截这几个坑之后,我把整个流程完整走了一遍。这篇文章就是给同样要做 SpringBoot + 云厂商SSL证书,完成 http转https 以及 ws转wss 的开发者写的,包含证书申请、格式转换、SpringBoot配置、前端改动,以及我在实操中踩过的几个坑。不管你是第一次接触 TLS,还是已经在生产环境被 WebSocket 握手失败折腾过,这篇都能给你一个直接可用的方案。

1. 先搞清楚为什么要折腾:HTTPS 和 WSS 不是锦上添花,是硬门槛

很多开发者觉得“我接口里又没有银行卡密码,转 HTTPS 干嘛?”但现实是,这件事已经从“建议开启”变成了“强制要求”。

1.1 浏览器和用户根本不给你“裸奔”的机会

现在的浏览器,对 HTTP 页面的态度非常明确:地址栏直接标“不安全”,密码框、支付页面、语音视频类功能甚至会限制使用。更致命的是 WebSocket 的 Mixed Content 策略:如果页面本身是 HTTPS,你却用ws://去连后端,浏览器直接拦截,控制台报错Mixed Content: The page at 'https://...' was loaded over HTTPS, but attempted to connect to an insecure WebSocket endpoint 'ws://...'。

这个报错不是后端能解决的,是浏览器层面的安全策略。也就是说,只要你的页面上了 HTTPS,WebSocket 就必须跟着上 WSS,否则功能直接废掉。这也是本次改造最直接的原因。

1.2 HTTPS 和 WSS 的关系:WSS = WebSocket over TLS

用生活化一点的说法:HTTP 和 HTTPS 之间的关系,就像普通快递和带密封条的快递。普通快递中途可能被人打开看过,密封条是让你确认“没人动过”的保障。WebSocket 是一条长连接通道,WS 是裸奔的,WSS 就是在通道外面再套一层 TLS 加密隧道。

所以核心结论是:WSS 并不是一种独立于 WebSocket 的协议,它就是在 TLS 连接之上跑 WebSocket。这意味着只要 SpringBoot 把 HTTPS 配好,WebSocket 端点天然就具备了升级为 WSS 的条件,不需要额外写一套鉴权或加密逻辑。理解了这一点,后面配置起来思路就清晰多了。

1.3 本次改造的目标拆解

我这次要处理的是一个典型的消息推送服务,主要目标有三个:

  • 所有 HTTP 请求自动跳转到 HTTPS,不能出现“用户访问了 http 地址结果白屏”的情况;
  • 前端 WebSocket 连接地址从ws://改成wss://,保证服务端和浏览器之间走加密通道;
  • 确保 WebSocket 握手、长连接推送在升级后不失效,兼容现有业务代码。

架构选择上,我直接让 SpringBoot 本身承担 TLS 终止,也就是 SpringBoot 内置 Tomcat 监听 443 端口并启用 HTTPS。另外还有一种常见方案是前端放 Nginx,由 Nginx 终止 TLS 后再转成 HTTP 反代到后端。这两种方案各有利弊,我会在第 4 节专门对比,你可以根据自己的部署环境选。

2. 证书准备:从云厂商控制台到 Java 能识别的密钥库

这是整个流程里最容易出问题的一步,因为很多人不知道“云厂商下载下来的证书,Java 不一定直接能用”。证书格式不对,SpringBoot 启动就会报错,而且报错信息特别容易误导人。

2.1 选哪种证书:免费单域名还是泛域名

各家云厂商都会提供免费证书,一般是单域名证书,有效期 3 个月。如果你只有一个域名,比如example.com,免费证书完全够用。但如果你的业务涉及多个二级域名,比如api.example.com、admin.example.com、ws.example.com,每个域名单独申请免费证书会比较麻烦,这时候要么申请泛域名证书(价格较高),要么用支持多域名 SAN 的证书。

我这次业务只有一个域名,所以直接用了云厂商的免费单域名证书。免费证书虽说过期快,但胜在零成本,配合到期提醒和自动续签流程也不会出太大问题。

注意:免费证书通常不支持 API 自动签发,到期需要手动重新申请。建议在日历上提前 1-2 周设置提醒,别等证书过期了才想起来。

2.2 申请与 DNS 验证实操

流程不复杂,但有一个环节容易卡住:DNS 验证。

在云厂商控制台搜索“SSL证书”,选择免费证书类型,填入需要绑定域名,然后提交申请。提交后通常有两种验证方式:文件验证和 DNS 验证。文件验证需要在网站根目录放一个指定文件,DNS 验证需要在域名解析记录里加一条 TXT 记录。

我强烈建议用 DNS 验证,一条_dnsauth开头的 TXT 记录,去域名服务商后台添加即可。添加解析记录后需要等几分钟到几十分钟生效,因为 DNS 有缓存。验证通过后,证书一般会在几分钟内签发。

2.3 下载什么格式:Nginx 版 PEM 只是第一步

这是关键中的关键。云厂商控制台下载证书时,通常可以选 Nginx、Apache、Tomcat、IIS 等格式。如果你打算让 SpringBoot 内置 Tomcat 直接用,最容易踩的坑就是下载了 Nginx 版之后,直接把.pem文件放进了 SpringBoot 配置。

SpringBoot 底层是 Java 生态,Java 的 SSL 配置需要的是密钥库(KeyStore),常见格式是 PKCS12(.p12或.pfx)和 JKS。Nginx 版下载下来的是一对 PEM 文件:证书文件(cert.pem)和私钥文件(key.pem),SpringBoot 没法直接读这对文件。

解决办法两种:

  1. 在云厂商控制台直接下载 Tomcat 版证书,得到的是 JKS 格式密钥库;
  2. 下载 Nginx 版,自己在本地用 openssl 转成 PKCS12。

我习惯用 openssl 转换,因为这样一来密钥库的别名和密码都是自己控制的,而且 PKCS12 比 JKS 更通用,SpringBoot 对 PKCS12 的支持也更友好。转换命令:

openssl pkcs12 -export \ -in cert.pem \ -inkey key.pem \ -out server.p12 \ -name myserver \ -passout pass:你的密码

参数说明:

  • -in:证书文件,PEM 格式;
  • -inkey:私钥文件,PEM 格式;
  • -out:输出的 PKCS12 密钥库文件名;
  • -name:密钥在密钥库中的别名,可以任意起,但要记住;
  • -passout:密钥库文件的访问密码。

注意:这个密码是你自定义的,不是云厂商提供的。密码会用在 SpringBoot 的key-store-password配置项上,千万别搞混。

2.4 证书文件放哪里与配置项拆解

转换好的server.p12文件,我建议放在 SpringBoot 项目的src/main/resources目录下,这样打包进 JAR 后不会丢,路径写classpath:server.p12就行。

如果你的证书不想打进 JAR,而是想放在服务器固定目录方便后续替换,可以放在/etc/certs/之类的路径,配置时写file:/etc/certs/server.p12。两种方式各有优势:放 resources 里部署省心,但每次换证书要重新打包;放外部目录的话,续期换证书只需要替换文件并重启进程。

对应的 SpringBoot 配置(application.yml):

server: port: 443 ssl: enabled: true key-store: classpath:server.p12 key-store-type: PKCS12 key-store-password: 你的密码 key-alias: myserver protocol: TLS

这几个配置项的含义分别是:密钥库位置、密钥库类型、密钥库访问密码、密钥别名、TLS 协议版本。key-alias必须和 openssl 转换时-name参数保持一致,否则启动会报找不到密钥的错误。

3. SpringBoot 开启 HTTPS:两种姿势与 HTTP 自动跳转

证书准备妥当后,SpringBoot 开启 HTTPS 其实有两条路可以走:纯配置文件的方式,和自定义 Tomcat 连接器的方式。两者差别很大。

3.1 姿势一:纯配置文件开 HTTPS,简单但会丢掉 HTTP

如果你只需要 HTTPS,不需要兼容 HTTP 访问,只需要在application.yml里加server.ssl.*那一坨配置就行。默认情况下,配置了 SSL 之后,SpringBoot 的内置 Tomcat 就只监听 HTTPS 端口,原来的 HTTP 端口直接失效。

这种方式的优点是配置改动最小,缺点也明显:用户如果手动输入http://example.com访问,会直接连接失败,而不是自动跳到 https。这在生产环境中不可接受,因为很多用户不会手动输入协议头,甚至有些老浏览器默认走 HTTP。

3.2 姿势二:保留 HTTP 同时自动跳转 HTTPS,推荐方案

我最终采用的是“HTTP 连接器 + HTTPS 连接器并存,HTTP 访问时由 Tomcat 自动 302 到 HTTPS”的方案。核心思路是:

  • SpringBoot 主连接器监听 443,启用 SSL;
  • 额外添加一个 HTTP 连接器监听 8080,redirectPort指向 443;
  • 通过 Tomcat 的SecurityConstraint触发跳转,所有请求都要求使用 CONFIDENTIAL 传输级别。

实现方式是自定义TomcatServletWebServerFactory:

@Configuration public class HttpsConfig { @Bean public TomcatServletWebServerFactory servletContainer() { TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory() { @Override protected void postProcessContext(Context context) { SecurityConstraint constraint = new SecurityConstraint(); constraint.setUserConstraint("CONFIDENTIAL"); SecurityCollection collection = new SecurityCollection(); collection.addPattern("/*"); constraint.addCollection(collection); context.addConstraint(constraint); } }; factory.addAdditionalTomcatConnectors(httpConnector()); return factory; } private Connector httpConnector() { Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); connector.setScheme("http"); connector.setPort(8080); connector.setSecure(false); connector.setRedirectPort(443); return connector; } }

这段代码的逻辑有必要拆开解释一下。postProcessContext是 Tomcat 启动时对上下文进行后处理的钩子,通过向上下文中添加一个SecurityConstraint,告诉 Tomcat:当前应用所有路径(/*)都要求使用保密传输。HTTP 连接器检测到这个约束后,会把请求重定向到redirectPort指定的 HTTPS 端口,也就是 443。

httpConnector()方法就是创建一个额外的 HTTP 连接器,监听 8080 端口,不启用 SSL,但设置redirectPort=443。最终效果是:

  • 用户访问http://example.com,Tomcat 返回 302,Location 指向https://example.com/;
  • 用户直接访问https://example.com,正常处理业务。

这里有个细节需要提醒:如果你把server.port配置成 443,那么 443 本身是 HTTPS 连接器,但 HTTP 连接器监听 8080,用户访问http://example.com其实访问的是 80 端口。所以生产环境还需要做一层端口转发,比较常见的是用 iptables 或防火墙规则把 80 转到 8080,443 转到 443。如果前面有 Nginx,则可以让 Nginx 把 80 的请求 301 到 https,或者反向代理到后端的 8080。

3.3 用浏览器和 curl 验证跳转是否生效

配置完成后,重启 SpringBoot,先确认进程启动没有报错。然后再做两件事:

第一步,浏览器访问http://example.com,看地址栏是否自动变成https://example.com。如果没变,说明跳转没生效。

第二步,用 curl 验证响应头:

curl -I http://example.com

正常情况下会看到类似这样的响应:

HTTP/1.1 302 Found Location: https://example.com/

再验证 HTTPS 正常访问:

curl -I https://example.com

如果返回HTTP/1.1 200,说明 HTTPS 配置成功。建议顺手加上-k参数替换成完整证书链验证(下文第 5 节会讲),但正常的curl不报证书错误,就说明证书链是完整可信的。

4. WebSocket 从 WS 到 WSS:后端注册与前端一行改动

HTTP 转 HTTPS 搞定之后,WebSocket 这边其实并没有那么复杂,因为 WSS 本质就是“WebSocket 跑在 TLS 上”。只要 SpringBoot 已经启用 HTTPS,后端 WebSocket 代码不用改,核心改动在前端连接地址。

4.1 后端 WebSocket 注册还在老地方

如果你的项目已经用了 WebSocket,后端配置通常长这样:

@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new MessageHandler(), "/ws/message") .addInterceptors(new HttpSessionHandshakeInterceptor()) .setAllowedOrigins("*"); } }

MessageHandler是你自己实现的WebSocketHandler,业务逻辑不用动。addInterceptors里加握手拦截器,通常用于在握手阶段从 HTTP Session 中取出用户信息放入 WebSocket Session 属性,这个逻辑也不受 TLS 影响。

唯一需要注意的是setAllowedOrigins。开发环境常见的是写*,表示允许所有来源跨域连接。但生产环境如果明确知道页面域名,建议收敛为具体域名,避免 WebSocket 被任意来源连接。特别是在 HTTPS 环境下,浏览器对跨域 WebSocket 的校验会更严格,来源域名不匹配会直接 403。

4.2 前端 ws 改 wss:改动就两行,但要注意环境判断

前端代码的改动确实很小,核心就是把ws://改成wss://。但直接写死并不稳妥,我建议根据当前页面协议动态判断。因为开发环境通常还是http://localhost,这时候 WebSocket 只能用ws://,如果一刀切改成wss://,本地开发会连不上。

推荐这样写:

const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${scheme}://${window.location.host}/ws/message`);

这段代码的意思是:如果页面是 HTTPS,就用wss://;如果页面是 HTTP(本地开发),就用ws://。同时window.location.host自动带出域名和端口,不用手写死,部署切换环境时少踩坑。

这里要多说一句:为什么 HTTPS 页面下不能用ws://?因为浏览器认为ws://是不安全传输,而页面本身已经是安全上下文,混用会触发 Mixed Content 拦截。你在控制台看到的报错不是后端不响应,而是浏览器直接拒绝发送连接请求。所以“后端代码没变,前端把协议头改掉”这个逻辑是对的。

4.3 如果 Nginx 在前面,WSS 怎么配:两种架构对比

很多生产环境不会让 SpringBoot 直接暴露 443,而是在前面挂一层 Nginx。这种做法有个很大的好处:TLS 终止在 Nginx,后端 SpringBoot 只需要监听 HTTP 和 WS,不用处理证书和密钥库,运维上更简单。

架构对比:

方案TLS 终止位置SpringBoot 是否需要证书适用场景
方案ASpringBoot 内置 Tomcat需要,配置 server.ssl.*部署简单,单体服务,端口直接暴露
方案BNginx不需要,后端走 HTTP/WS多服务、负载均衡、统一证书管理

如果走方案B,Nginx 的配置要点是 WebSocket 协议升级头,不能少,否则握手失败:

server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate_key /etc/nginx/certs/key.pem; location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 300s; } }

关键就两行:proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection "upgrade";。WebSocket 协议升级依赖这两个头,Nginx 默认会忽略 HTTP 头里的 Upgrade,必须显式传过去。如果漏了,前端连接会在握手阶段被挂断,报错是WebSocket connection to 'wss://example.com/ws/message' failed。

后端这时候的application.yml不需要配置server.ssl.*,直接监听 8080:

server: port: 8080

前端地址还是wss://example.com/ws/message,因为浏览器看到的是 Nginx 提供的 443 加密连接,Nginx 再往后的细节浏览器并不知道。

我在这次项目中先用方案A实现了整体功能,后面上部署环境时切到了方案B。两种方案各有取舍,但核心结论是一致的:不管 TLS 在哪一层终止,前端连接地址都必须用wss://,这一点不会变。

5. 实测中的坑与排查清单:证书、跳转、握手问题一网打尽

这一节必须单独拿出来讲,因为整个改造过程中,真正让人抓狂的不是“配置怎么写”,而是“报错看不懂”。我把这次遇到的高频问题整理成一份排查清单,以后遇到类似情况可以直接对照。

5.1 证书相关错误:报错信息看不懂,多半是格式和密码问题

证书问题最典型的报错是启动时 SpringBoot 直接抛异常:

java.io.IOException: failed to decrypt safe contents entry

或者:

java.security.UnrecoverableKeyException: Cannot recover key

这两种基本都是密码不匹配。检查一下application.yml里的key-store-password是不是和 openssl 转换时的-passout参数一致。注意 Java 密钥库有“密钥库密码”和“密钥密码”两层概念,SpringBoot 默认用的是同一个值,openssl 转换时如果没有单独指定条目密码,那么两层都应该用-passout里的值。

另一种情况是报错:

java.io.IOException: DerInputStream.getLength(): lengthTag=127, too big.

这种常见于直接把 PEM 文件当成密钥库用。解决办法就是把 PEM 转换成 PKCS12 或 JKS,别图省事直接指到.pem文件上。

还有一类问题是证书能启动,但浏览器访问时报SSL_ERROR_BAD_CERT_DOMAIN。这说明证书域名和访问域名不匹配,比如证书申请的是example.com,你访问的是www.example.com。免费单域名证书不会自动包含www子域,申请时要想清楚到底用哪个域名。

5.2 HTTP 跳转相关问题:死循环、端口丢失、curl 永远 301

跳转配置好之后,最容易遇到的是“跳转死循环”。浏览器一直在http和https之间来回跳,最后提示 Too Many Redirects。原因通常是 HTTP 连接器的redirectPort没有配对,或者 SecurityConstraint 里写了两次重定向规则、又或者前面 Nginx 已经 301 到 https,而后端又在强跳。排查思路很直接:用 curl 分步看响应。

curl -I http://example.com

如果返回的 Location 是http://example.com而不是https://example.com,说明 Tomcat 的redirectPort没生效。检查额外连接器配置里是否设置了setRedirectPort(443)。

还有一个容易忽略的点:如果你把server.port配成了 443,但操作系统上 443 端口没放行,或者被别的进程占用,SpringBoot 会启动失败。用netstat -tlnp | grep 443检查端口占用,这是每个生产环境部署都会遇到的常规操作。

5.3 WebSocket 被浏览器拦截与握手失败:403、Mixed Content、Upgrade

这个分区是最复杂的,我把实际匹配情况整理成了表格,方便按图索骥:

报错表现可能原因解决办法
Mixed Content 报错HTTPS 页面里用ws://前端改成wss://
WebSocket connection failedNginx 没透传 Upgrade 头加proxy_set_header Upgrade $http_upgrade;
HTTP 403跨域来源被拒绝调整setAllowedOrigins,或检查 Nginx 后端的 Host 头
net::ERR_SSL_PROTOCOL_ERROR访问 wss 时后端根本没启用 TLS确认 SpringBoot 已配置 SSL,或 Nginx 443 已启用证书
连接能建立但马上断开握手拦截器抛异常 / session 校验失败看 SpringBoot 日志中的堆栈信息

最坑的是 403。WebSocket 握手本质上是一个 HTTP 请求,升级协议,所以它也遵守 HTTP 的同源策略。SpringBoot 的setAllowedOrigins如果写了*且浏览器带了 Origin 头,某些版本下依然可能出问题。稳妥做法是显式列出允许的来源:

.setAllowedOrigins("https://example.com")

不过如果你用了 Nginx 反代,后端看到的 Origin 可能是浏览器端的正确值,也可能被改掉,具体取决于 Nginx 是否设置了proxy_set_header Origin $http_origin。如果发现后端日志里 Origin 不对,去 Nginx 配置里确认。

另外,WSS 握手失败时,前端经常只显示一句failed: Error in connection establishment: net::ERR_SSL_PROTOCOL_ERROR,这句话信息量太少。我的排查习惯是直接用命令行工具绕开浏览器测试:

openssl s_client -connect example.com:443 -servername example.com

如果证书链正常,再用wscat这类 WebSocket 客户端测试连接:

wscat -c wss://example.com/ws/message

这样能把“浏览器拦截”和“后端拒绝握手”两个层面的问题区分开,排查效率翻倍。

5.4 免费证书 3 个月到期,续期替换别忘重启

免费证书到期是个大坑,尤其是做消息推送这类长连接服务,证书一过期,WebSocket 连接会全部断掉,用户那边表现就是推送突然不可用。续期后需要替换两个地方:云厂商重新签发证书得到新的 PEM 文件,然后重新转换成server.p12覆盖旧文件,最后重启 SpringBoot 进程。

如果是方案B(Nginx 终止 TLS),续期更简单,替换 PEM 文件后nginx -s reload即可,不需要重启后端进程。这也是生产环境建议把 TLS 放在 Nginx 的原因之一。

我在实际项目里还做了一个小方案:把证书续期的提醒提前挂在日历上,每月初检查一次有效期。用私钥工具或openssl x509 -enddate -noout -in cert.pem可以快速查看证书过期时间,操作很简单,但能避免一次线上事故。

最后再说两句

这次改造做完,我的体会是:最耗时的不是 SpringBoot 配置,而是证书格式转换和浏览器策略这两道坎。很多人卡住都卡在“云厂商下载的证书明明是好的,怎么 SpringBoot 就是不认”这一类问题上。所以先确认 TLS 在哪一层终止,再决定配证书还是配 Nginx,顺序搞对就顺了。

另一个经验是:改动上线前,先用curl -vk验证证书链和跳转,再用wscat验证 WebSocket 握手,整个过程比打开浏览器反复刷新快得多。尤其是 WSS 这类长连接服务,浏览器控制台的报错往往被安全策略包装过,不如命令行工具来得直接。

如果你目前也在折腾 SpringBoot 的 HTTPS 和 WSS,希望这篇文章能帮你少踩几个坑。配置本身不复杂,把思路理清楚,证书格式别搞错,剩下的事就是水到渠成。

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

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

立即咨询