目标:只在需要时开启 一个 HTTPS 监听端口,支持 Keystore(JKS/PKCS12) 与 PEM(证书+私钥) 二选一切换;可选在 N 天后自动强制跳转到 HTTPS。避免同一应用内出现多个 HTTPS 监听或配置冲突。
背景与约束
- Spring Boot 使用嵌入式 Tomcat。
- 需要在 一个开关下控制是否开启额外的 HTTPS 端口。
- 仅允许 一种 HTTPS 配置生效(Keystore 或 PEM),不允许并存。
- 若启用 N 天后强制跳转,在到期前允许 HTTP 直连,到期后自动 301 到 HTTPS。
注意:不要同时再配置
server.ssl.*(那是主端口的 HTTPS),否则会额外创建一个 HTTPS 监听,违背“单一 HTTPS”的目标。
总体方案
- 使用
server.https.enabled控制是否创建附加的 HTTPS 连接器;server.https.port指定端口。 - 使用
ssl.mode在KEYSTORE与PEM之间切换,只会应用一种。 - 通过
WebServerFactoryCustomizer<TomcatServletWebServerFactory>动态向 Tomcat 添加 一个 HTTPS 连接器;未开启时不添加。 - (可选)通过 Servlet 过滤器在超过指定天数后,对 HTTP 请求返回 301 重定向到 HTTPS。
YAML 配置
application.yml 示例:
server:
port: 8080 # 主端口(HTTP)
https:
enabled: true # 开关:true=启用附加 HTTPS;false=完全不开
port: 8443
ssl:
mode: KEYSTORE # 二选一:KEYSTORE 或 PEM
# --- 若选择 KEYSTORE 模式,填以下 ---
key-store: ./keystore.p12
key-store-type: PKCS12
key-store-password: 12345678
key-password: 12345678 # 可选,不填则等于 key-store-password
key-alias: tomcat # 可选
# --- 若选择 PEM 模式,填以下 ---
# certificate: ./server.crt # 或 fullchain.pem
# certificate-private-key: ./server.key
# certificate-private-key-password: "" # 可选,.key 若加密
# certificate-chain: ./chain.pem # 可选(中间证书链)仅需修改
ssl.mode即可在两种模式间切换。不要再配server.ssl.*,以免产生第二个 HTTPS 监听。
Tomcat 配置实现(单一 HTTPS,模式可切换)
类:TomcatConfig.java
package com.ruoyi.web.core.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.Connector;
import org.apache.coyote.http11.Http11NioProtocol;
import org.apache.tomcat.util.net.SSLHostConfig;
import org.apache.tomcat.util.net.SSLHostConfigCertificate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StringUtils;
import java.io.File;
import java.io.FileNotFoundException;
@Slf4j
@Configuration
public class TomcatConfig {
/* -------- 开关 & 端口 -------- */
@Value("${server.https.enabled:false}")
private boolean httpsEnabled;
@Value("${server.https.port:8443}")
private int httpsPort;
/* -------- 证书模式:KEYSTORE 或 PEM -------- */
@Value("${ssl.mode:KEYSTORE}")
private String sslMode; // "KEYSTORE" or "PEM"
/* -------- Keystore 配置 -------- */
@Value("${ssl.key-store:}")
private String keyStore;
@Value("${ssl.key-store-type:PKCS12}")
private String keyStoreType;
@Value("${ssl.key-store-password:}")
private String keyStorePassword;
@Value("${ssl.key-password:}") // 可选,不填则回退为 keyStorePassword
private String keyPassword;
@Value("${ssl.key-alias:}")
private String keyAlias;
/* -------- PEM 配置 -------- */
@Value("${ssl.certificate:}")
private String certificateFile;
@Value("${ssl.certificate-private-key:}")
private String certificateKeyFile;
@Value("${ssl.certificate-private-key-password:}")
private String certificateKeyPassword;
@Value("${ssl.certificate-chain:}")
private String certificateChainFile;
/**
* 采用 “定制器” 而不是自定义 ServletWebServerFactory Bean,
* 避免覆盖 Boot 的其他自动配置,并且只在开关开启时添加一个 HTTPS 连接器。
*/
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> httpsConnectorCustomizer() {
return factory -> {
if (!httpsEnabled) {
log.info("server.https.enabled=false,跳过附加 HTTPS 连接器创建。");
return;
}
Connector https = buildHttpsConnector();
factory.addAdditionalTomcatConnectors(https);
log.info("已启用附加 HTTPS 监听,端口:{},模式:{}", httpsPort, sslMode);
};
}
/* ---------------- 内部实现 ---------------- */
private Connector buildHttpsConnector() {
Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
connector.setScheme("https");
connector.setSecure(true);
connector.setPort(httpsPort);
Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
protocol.setSSLEnabled(true);
String mode = sslMode == null ? "KEYSTORE" : sslMode.trim().toUpperCase();
switch (mode) {
case "PEM":
applyPem(protocol);
break;
case "KEYSTORE":
applyKeystore(protocol);
break;
default:
throw new IllegalStateException("不支持的 ssl.mode:" + sslMode + "(只能是 KEYSTORE 或 PEM)");
}
return connector;
}
/** Keystore(JKS/PKCS12)模式 */
private void applyKeystore(Http11NioProtocol protocol) {
require(hasText(keyStore), "ssl.key-store 必须配置(Keystore 模式)");
require(hasText(keyStorePassword), "ssl.key-store-password 必须配置(Keystore 模式)");
protocol.setKeystoreFile(toAbsPath(keyStore));
protocol.setKeystoreType(hasText(keyStoreType) ? keyStoreType : "PKCS12");
protocol.setKeystorePass(keyStorePassword);
protocol.setKeyPass(hasText(keyPassword) ? keyPassword : keyStorePassword);
if (hasText(keyAlias)) {
protocol.setKeyAlias(keyAlias);
}
log.debug("Keystore 模式:file={}, type={}, alias={}", protocol.getKeystoreFile(), protocol.getKeystoreType(), keyAlias);
}
/** PEM(certificate + private key [+ chain])模式 */
private void applyPem(Http11NioProtocol protocol) {
require(hasText(certificateFile), "ssl.certificate 必须配置(PEM 模式)");
require(hasText(certificateKeyFile), "ssl.certificate-private-key 必须配置(PEM 模式)");
SSLHostConfig cfg = new SSLHostConfig();
SSLHostConfigCertificate cert = new SSLHostConfigCertificate(cfg, SSLHostConfigCertificate.Type.RSA);
cert.setCertificateFile(toAbsPath(certificateFile));
cert.setCertificateKeyFile(toAbsPath(certificateKeyFile));
if (hasText(certificateKeyPassword)) {
cert.setCertificateKeyPassword(certificateKeyPassword);
}
if (hasText(certificateChainFile)) {
cert.setCertificateChainFile(toAbsPath(certificateChainFile));
}
cfg.addCertificate(cert);
protocol.addSslHostConfig(cfg);
log.debug("PEM 模式:cert={}, key={}, chain={}", cert.getCertificateFile(), cert.getCertificateKeyFile(), cert.getCertificateChainFile());
}
/* ---------------- 小工具 ---------------- */
private static boolean hasText(String s) { return StringUtils.hasText(s); }
private static void require(boolean ok, String msg) { if (!ok) throw new IllegalStateException(msg); }
/** 支持 classpath: 与相对路径 → 绝对路径 */
private static String toAbsPath(String loc) {
if (!StringUtils.hasText(loc)) return loc;
try {
File f = ResourceUtils.getFile(loc); // 支持 "classpath:"
return f.getAbsolutePath();
} catch (FileNotFoundException e) {
return new File(loc).getAbsolutePath();
}
}
}可选:N 天后自动强制跳转到 HTTPS
思路:首次运行时记录“起始时间”(或使用固定 start-date),超过 after-days 后,对所有非 HTTPS 请求返回 301 到对应的 https:// URL。支持反向代理场景(X-Forwarded-* 头)。
YAML
force-https:
after-days: 7 # N 天后强制
https-port: 8443 # 外部可见的 HTTPS 端口(对外是 443 时可不写)
state-file: ${user.home}/.ruoyi/https-state.txt # 持久化首次运行时间
respect-forward-headers: true # 反向代理后建议开启
# start-date: 2025-01-01T00:00:00Z # 可选:固定日期,优先生效属性类(可选)
package com.ruoyi.web.core.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "force-https")
public class ForceHttpsProperties {
private int afterDays = 0;
private String startDate; // ISO8601,若配置则优先
private String stateFile;
private Integer httpsPort;
private boolean respectForwardHeaders = true;
// getters/setters 省略
}判定器
package com.ruoyi.web.core.config;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.*;
import java.time.format.DateTimeParseException;
public class HttpsEnforceDecider {
private final ForceHttpsProperties props;
private final Instant baseInstant;
public HttpsEnforceDecider(ForceHttpsProperties props) {
this.props = props;
this.baseInstant = resolveBaseInstant();
}
public boolean shouldEnforce() {
if (props.getAfterDays() <= 0) return false;
Instant threshold = baseInstant.plus(Duration.ofDays(props.getAfterDays()));
return Instant.now().isAfter(threshold);
}
private Instant resolveBaseInstant() {
if (props.getStartDate() != null && !props.getStartDate().isEmpty()) {
try { return Instant.parse(props.getStartDate()); } catch (DateTimeParseException ignored) {}
}
String state = props.getStateFile();
Instant now = Instant.now();
if (state == null || state.isEmpty()) return now;
Path path = Paths.get(state);
try {
if (Files.notExists(path)) {
if (path.getParent() != null) Files.createDirectories(path.getParent());
Files.write(path, Long.toString(now.getEpochSecond()).getBytes(StandardCharsets.UTF_8),
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
return now;
} else {
String txt = new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim();
long epoch = Long.parseLong(txt);
return Instant.ofEpochSecond(epoch);
}
} catch (IOException | NumberFormatException e) {
return now;
}
}
}重定向过滤器(Boot 2 用 javax.*,Boot 3 改为 jakarta.*)
package com.ruoyi.web.core.config;
import org.springframework.core.Ordered;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.*;
import java.io.IOException;
public class HttpsRedirectFilter extends OncePerRequestFilter implements Ordered {
private final HttpsEnforceDecider decider;
private final ForceHttpsProperties props;
public HttpsRedirectFilter(HttpsEnforceDecider decider, ForceHttpsProperties props) {
this.decider = decider;
this.props = props;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain)
throws ServletException, IOException {
boolean alreadyHttps =
req.isSecure() ||
(props.isRespectForwardHeaders() && "https".equalsIgnoreCase(req.getHeader("X-Forwarded-Proto")));
if (!alreadyHttps && decider.shouldEnforce()) {
String host = headerOr(req, "X-Forwarded-Host", req.getServerName());
String reqUri = req.getRequestURI();
String qs = req.getQueryString();
Integer httpsPort = props.getHttpsPort();
if (httpsPort == null || httpsPort <= 0) {
httpsPort = req.getServerPort();
}
String portPart = (httpsPort == 443 || httpsPort == null) ? "" : ":" + httpsPort;
String location = "https://" + host + portPart + (reqUri == null ? "" : reqUri) + (qs == null ? "" : "?" + qs);
resp.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
resp.setHeader("Location", location);
return;
}
chain.doFilter(req, resp);
}
private static String headerOr(HttpServletRequest req, String name, String fallback) {
String v = req.getHeader(name);
return (v == null || v.isEmpty()) ? fallback : v;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 10;
}
}装配
package com.ruoyi.web.core.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(ForceHttpsProperties.class)
public class ForceHttpsAutoConfig {
@Bean
public HttpsEnforceDecider httpsEnforceDecider(ForceHttpsProperties props) {
return new HttpsEnforceDecider(props);
}
@Bean
public FilterRegistrationBean<HttpsRedirectFilter> httpsRedirectFilter(HttpsEnforceDecider decider,
ForceHttpsProperties props) {
FilterRegistrationBean<HttpsRedirectFilter> reg = new FilterRegistrationBean<>();
reg.setFilter(new HttpsRedirectFilter(decider, props));
reg.setOrder(new HttpsRedirectFilter(decider, props).getOrder());
reg.addUrlPatterns("/*");
return reg;
}
}反向代理部署时,建议同时开启:
- Boot 3:
server.forward-headers-strategy=framework- Boot 2:
server.use-forward-headers=true
测试与验证
-
HTTP/HTTPS 监听检查
- 启动应用后检查日志:应只有一个“已启用附加 HTTPS 监听,端口:xxxx”的输出;
netstat -tnlp | grep :8443(或使用lsof -i)确认端口状态。
-
证书链
- 访问
https://host:8443/actuator/health(或任意接口); - 浏览器/
curl -vk检查证书链是否完整;缺链时补全ssl.certificate-chain或使用fullchain.pem。
- 访问
-
N 天后强制跳转
- 设置
after-days=0或start-date到过去,立即验证 301; - 代理后验证
X-Forwarded-Proto/Host的处理是否正确。
- 设置
常见问题与排查
1. 出现第二个 HTTPS 监听
- 排查是否仍配置了
server.ssl.*(主端口 HTTPS)。如有,删除或关闭。
2. “Could not autowire… There is more than one bean of …”
- 同一个配置类(如
ForceHttpsProperties)重复注册导致:- 只保留
@EnableConfigurationProperties(ForceHttpsProperties.class),不要再手写@Bean @ConfigurationProperties; - 或相反,只手写
@Bean,不要额外@EnableConfigurationProperties。二选一。
- 只保留
3. PEM 无法加载
- 确保使用 JSSE/NIO(本方案使用
Http11NioProtocol); - 检查
certificate、certificate-private-key路径是否正确(支持classpath:与相对路径); - 私钥若已加密,需要配置
ssl.certificate-private-key-password。
4. 浏览器提示证书不被信任
- 使用受信任 CA 证书;自签名证书仅用于测试;
- 补全中间证书链。
5. HSTS
- 仅在 HTTPS 响应中添加
Strict-Transport-Security;首次跳转一般不加,避免锁死错误域名/端口。
附录:证书与密钥准备
生成 PKCS#12(Keystore)
# 由 PEM 转为 PKCS12
openssl pkcs12 -export \
-in server.crt -inkey server.key \
-certfile chain.pem \
-out keystore.p12 -name tomcat自签名测试证书(仅测试)
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout server.key -out server.crt -days 365 \
-subj "/CN=localhost"收尾建议
- 生产环境统一使用反向代理(Nginx/HAProxy/Envoy)做 TLS 终止与证书管理,应用侧仍保留上述能力以备直连或内网场景。
- 版本控制中不要提交真实私钥与凭据,使用占位或样例文件。