目标:只在需要时开启 一个 HTTPS 监听端口,支持 Keystore(JKS/PKCS12)PEM(证书+私钥) 二选一切换;可选在 N 天后自动强制跳转到 HTTPS。避免同一应用内出现多个 HTTPS 监听或配置冲突。


背景与约束

  • Spring Boot 使用嵌入式 Tomcat
  • 需要在 一个开关下控制是否开启额外的 HTTPS 端口。
  • 仅允许 一种 HTTPS 配置生效(Keystore PEM),不允许并存
  • 若启用 N 天后强制跳转,在到期前允许 HTTP 直连,到期后自动 301 到 HTTPS。

注意:不要同时再配置 server.ssl.*(那是主端口的 HTTPS),否则会额外创建一个 HTTPS 监听,违背“单一 HTTPS”的目标。


总体方案

  1. 使用 server.https.enabled 控制是否创建附加的 HTTPS 连接器;server.https.port 指定端口。
  2. 使用 ssl.modeKEYSTOREPEM 之间切换,只会应用一种
  3. 通过 WebServerFactoryCustomizer<TomcatServletWebServerFactory> 动态向 Tomcat 添加 一个 HTTPS 连接器;未开启时不添加。
  4. (可选)通过 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

测试与验证

  1. HTTP/HTTPS 监听检查

    • 启动应用后检查日志:应只有一个“已启用附加 HTTPS 监听,端口:xxxx”的输出;
    • netstat -tnlp | grep :8443(或使用 lsof -i)确认端口状态。
  2. 证书链

    • 访问 https://host:8443/actuator/health(或任意接口);
    • 浏览器/curl -vk 检查证书链是否完整;缺链时补全 ssl.certificate-chain 或使用 fullchain.pem
  3. N 天后强制跳转

    • 设置 after-days=0start-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);
  • 检查 certificatecertificate-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 终止与证书管理,应用侧仍保留上述能力以备直连或内网场景。
  • 版本控制中不要提交真实私钥与凭据,使用占位或样例文件。