Spring Boot实现HTTPS双向认证

前言

为什么需要双向认证?

传统的HTTPS单向认证虽然能保护客户端到服务器的通信安全,但却无法验证调用者的真实身份。

这种"只认服务器,不认客户端"的模式在现代化的应用场景中显得力不从心。无论是金融支付系统、企业核心业务平台,还是医疗信息系统,都需要更严格的安全机制来保护敏感数据和关键业务。

双向认证的核心价值

真正的身份验证

  • 客户端和服务器双方都持有数字证书
  • 证书与具体身份(企业、服务、个人)强绑定
  • 无法被轻易伪造或窃取

防止中间人攻击

  • 即使在网络被劫持的情况下,攻击者仍无法伪造客户端身份
  • 每次握手都会验证客户端证书的有效性

满足合规要求

  • 金融行业:PCI DSS要求对敏感数据访问进行强认证
  • 医疗行业:HIPAA要求对电子健康记录访问进行身份验证
  • 政府系统:等保2.0要求重要信息系统采用双向认证

什么是HTTPS双向认证?

单向认证 vs 双向认证

单向认证(我们熟悉的方式):

复制代码
客户端                              服务器
  |                                  |
  |-- 发送HTTPS请求 ------------------>|
  |<-- 返回服务器证书 -----------------|
  |-- 验证证书有效性 ----------------->|
  |-- 建立加密连接 -------------------->|
  |<-- 正常数据交换 -------------------|

问题:服务器无法验证客户端身份,任何人都可连接

双向认证(更安全的方式):

复制代码
客户端                              服务器
  |                                  |
  |-- 发送HTTPS请求 ------------------>|
  |<-- 返回服务器证书 -----------------|
  |<-- 请求客户端证书 -----------------|
  |-- 验证服务器证书 ----------------->|
  |-- 发送客户端证书 ----------------->|
  |<-- 验证客户端证书 -----------------|
  |-- 建立双向认证连接 ----------------|
  |<-- 正常数据交换 -------------------|

双向认证的独特优势

1. 强身份绑定

复制代码
传统方式:
客户端 → 用户名/密码 → 服务器
(身份信息可以被任意冒用)

双向认证:
客户端 → 数字证书 → 服务器
(证书与具体实体强绑定,无法伪造)

2. 零信任架构实现

java 复制代码
// 双向认证下的安全访问
@RestController
public class SecureController {

    @GetMapping("/api/financial-data")
    public ResponseEntity<String> getFinancialData(Authentication auth) {
        // 获取客户端证书信息
        X509Certificate cert = (X509Certificate) auth.getCredentials();

        // 验证证书指纹是否在白名单中
        if (!isTrustedCertificate(cert)) {
            return ResponseEntity.status(403).body("Untrusted client");
        }

        // 验证证书有效期
        try {
            cert.checkValidity();
        } catch (Exception e) {
            return ResponseEntity.status(403).body("Certificate expired");
        }

        // 验证证书用途
        String subject = cert.getSubjectX500Principal().getName();
        if (!subject.contains("FINANCIAL_CLIENT")) {
            return ResponseEntity.status(403).body("Insufficient privileges");
        }

        return ResponseEntity.ok(getSensitiveFinancialData());
    }
}

应用场景

1. 金融行业应用

  • 银行核心系统间的安全通信
  • 第三方支付接口的安全接入
  • 证券交易系统的客户端认证
  • 保险理赔接口的访问控制

2. 企业级应用

  • 微服务架构下的服务间认证
  • ERP、CRM等关键系统的访问控制
  • 财务系统的安全数据传输
  • 人事管理系统的权限控制

3. 政府和公共事业

  • 电子政务系统的安全接入
  • 税务管理系统的身份验证
  • 医疗信息系统的数据保护
  • 公安信息平台的安全通信

4. 开放平台

  • 第三方开发者的API接入
  • 合作伙伴的系统集成
  • 高价值数据接口的访问控制
  • 运营管理后台的安全访问

在实际项目中,建议根据业务需求和安全等级要求来选择合适的认证方案。对于高安全级别的系统,双向认证是一个值得考虑的选择。

关键代码实现

项目依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

证书生成

1. 生成CA私钥和证书
bash 复制代码
# 生成CA私钥
openssl genrsa -out ca-key.pem 2048

# 生成CA证书
openssl req -new -x509 -days 365 -key ca-key.pem -out ca-cert.pem \
  -subj "/C=CN/ST=Beijing/L=Beijing/O=MyCompany/OU=IT/CN=MyCA"
2. 生成服务器证书
bash 复制代码
# 生成服务器私钥
openssl genrsa -out server-key.pem 2048

# 生成服务器证书请求
openssl req -new -key server-key.pem -out server-cert.csr \
  -subj "/C=CN/ST=Beijing/L=Beijing/O=MyCompany/OU=IT/CN=localhost"

# 用CA签名服务器证书
openssl x509 -req -days 365 -in server-cert.csr -CA ca-cert.pem \
  -CAkey ca-key.pem -CAcreateserial -out server-cert.pem
3. 生成客户端证书
bash 复制代码
# 生成客户端私钥
openssl genrsa -out client-key.pem 2048

# 生成客户端证书请求
openssl req -new -key client-key.pem -out client-cert.csr \
  -subj "/C=CN/ST=Beijing/L=Beijing/O=MyCompany/OU=IT/CN=Client"

# 用CA签名客户端证书
openssl x509 -req -days 365 -in client-cert.csr -CA ca-cert.pem \
  -CAkey ca-key.pem -CAcreateserial -out client-cert.pem
4. 生成Java密钥库
bash 复制代码
# 将PEM格式转换为PKCS12格式
openssl pkcs12 -export -in server-cert.pem -inkey server-key.pem \
  -certfile ca-cert.pem -name server -out server.p12 -password pass:changeit

openssl pkcs12 -export -in client-cert.pem -inkey client-key.pem \
  -certfile ca-cert.pem -name client -out client.p12 -password pass:changeit

# 将PKCS12转换为JKS格式(可选)
keytool -importkeystore -srckeystore server.p12 -srcstoretype PKCS12 \
  -destkeystore server.jks -deststoretype JKS -srcstorepass changeit -deststorepass changeit

keytool -importkeystore -srckeystore client.p12 -srcstoretype PKCS12 \
  -destkeystore client.jks -deststoretype JKS -srcstorepass changeit -deststorepass changeit

Spring Boot配置

1. 配置文件设置

application.yml:

yaml 复制代码
server:
  port: 8443
  ssl:
    enabled: true
    key-store: classpath:server.jks
    key-store-password: changeit
    key-store-type: JKS
    key-alias: server
    trust-store: classpath:server.jks
    trust-store-password: changeit
    trust-store-type: JKS
    client-auth: need  # 需要客户端证书
2. SSL配置类
java 复制代码
@Configuration
public class SslConfig {

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                SecurityConstraint securityConstraint = new SecurityConstraint();
                securityConstraint.setUserConstraint("CONFIDENTIAL");
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/*");
                securityConstraint.addCollection(collection);
                context.addConstraint(securityConstraint);
            }
        };

        tomcat.addAdditionalTomcatConnectors(redirectConnector());
        return tomcat;
    }

    private Connector redirectConnector() {
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setScheme("http");
        connector.setPort(8080);
        connector.setSecure(false);
        connector.setRedirectPort(8443);
        return connector;
    }
}
3. 安全配置
java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .x509(x509 -> x509
                .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
                .userDetailsService(userDetailsService())
            )
            .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> {
            if ("Client".equals(username)) {
                return User.withUsername(username)
                    .password("")
                    .roles("USER")
                    .build();
            }
            throw new UsernameNotFoundException("User not found");
        };
    }
}
4. 示例控制器
java 复制代码
@RestController
public class ApiController {

    @GetMapping("/public/info")
    public ResponseEntity<String> publicInfo() {
        return ResponseEntity.ok("This is public information");
    }

    @GetMapping("/secure/data")
    public ResponseEntity<String> secureData(Authentication authentication) {
        String username = authentication.getName();
        return ResponseEntity.ok("Hello " + username + ", this is secure data!");
    }

    @GetMapping("/api/certificate-info")
    public ResponseEntity<Map<String, Object>> getCertificateInfo(HttpServletRequest request) {
        X509Certificate[] certificates = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate");

        Map<String, Object> response = new HashMap<>();

        if (certificates != null && certificates.length > 0) {
            X509Certificate cert = certificates[0];
            response.put("subject", cert.getSubjectDN().toString());
            response.put("issuer", cert.getIssuerDN().toString());
            response.put("validFrom", cert.getNotBefore());
            response.put("validTo", cert.getNotAfter());
            response.put("serialNumber", cert.getSerialNumber().toString());
        } else {
            response.put("error", "No client certificate found");
        }

        return ResponseEntity.ok(response);
    }
}
5. 证书验证逻辑
java 复制代码
@Component
public class CustomX509Validator {

    private static final Logger logger = LoggerFactory.getLogger(CustomX509Validator.class);

    // 黑名单存储(实际项目中应该从数据库或配置文件读取)
    private final Set<String> blacklistedSerialNumbers = new HashSet<>(Arrays.asList(
        "1234567890ABCDEF1234567890ABCDEF",
        "FEDCBA0987654321FEDCBA0987654321"
    ));

    private final Set<String> blacklistedSubjectDNs = new HashSet<>(Arrays.asList(
        "CN=BlacklistedClient, OU=Blacklisted Dept, O=Blacklisted Corp, C=US",
        "CN=RevokedClient, OU=Revoked Dept, O=Revoked Corp, C=CN"
    ));

    // 允许的组织列表(白名单)
    private final Set<String> allowedOrganizations = new HashSet<>(Arrays.asList(
        "DemoCompany"
    ));

    // 允许的CA序列号(用于验证证书链)
    private final Set<String> trustedRootCASerials = new HashSet<>(Arrays.asList(
        "3EE5FAF49D1073C87F738E69DF71A8E1CBA0752"  // 我们的根CA序列号
    ));

    // 吊销证书的CRL文件路径(生产环境中应该配置)
    private final String crlPath = null; // 配置为null,使用在线检查替代

    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    /**
     * 验证客户端证书 - 优化版本
     * 实现真正的证书链验证、黑名单检查和签名验证
     */
    public ValidationResult validateCertificate(X509Certificate cert) {
        ValidationResult result = new ValidationResult();

        try {
            logger.info("开始证书验证: {}", cert.getSubjectX500Principal().getName());

            // 1. 证书有效期验证
            validateCertificateValidity(cert, result);

            // 2. 证书黑名单检查
            validateBlacklist(cert, result);

            // 3. 证书链验证(真正的签名验证)
            validateCertificateChain(cert, result);

            // 4. 证书组织验证
            validateOrganization(cert, result);

            // 5. 证书密钥强度验证
            validateKeyStrength(cert, result);

            // 6. 证书扩展验证
            //validateExtensions(cert, result);

            // 7. 总体验证结果
            if (!result.hasErrors()) {
                result.setValid(true);
                result.addInfo("证书验证通过 - 所有检查均通过");
            }

        } catch (Exception e) {
            result.addError("证书验证过程中发生异常: " + e.getMessage());
            logger.error("证书验证异常", e);
        }

        // 记录验证结果
        logValidationResult(cert, result);

        return result;
    }

    /**
     * 1. 验证证书有效期
     */
    private void validateCertificateValidity(X509Certificate cert, ValidationResult result) {
        try {
            cert.checkValidity();
            Date now = new Date();
            Date notAfter = cert.getNotAfter();
            long daysUntilExpiry = (notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);

            result.addInfo(String.format("证书有效期验证通过 - 剩余%d天", daysUntilExpiry));

            if (daysUntilExpiry < 30) {
                result.addWarning(String.format("证书将在%d天后过期,请及时更新", daysUntilExpiry));
            }
        } catch (CertificateExpiredException e) {
            result.addError("证书已过期: " + e.getMessage());
        } catch (CertificateNotYetValidException e) {
            result.addError("证书尚未生效: " + e.getMessage());
        }
    }

    /**
     * 2. 验证证书黑名单
     */
    private void validateBlacklist(X509Certificate cert, ValidationResult result) {
        // 自定义黑名单规则校验
        String serialNumber = cert.getSerialNumber().toString(16).toUpperCase();
        if (blacklistedSerialNumbers.contains(serialNumber)) {
            result.addError("证书序列号在黑名单中: " + serialNumber);
            return;
        }

        String subjectDN = cert.getSubjectX500Principal().getName();
        if (blacklistedSubjectDNs.contains(subjectDN)) {
            result.addError("证书主题在黑名单中: " + subjectDN);
            return;
        }

        //todo 基于CRL、OCSP等进行在线检查

        result.addInfo("证书黑名单验证通过");
    }

    /**
     * 3. 验证证书链
     */
    private void validateCertificateChain(X509Certificate cert, ValidationResult result) {
        try {
            // 从classpath加载根CA证书
            X509Certificate rootCACert = null;
            try (InputStream caCertStream = getClass().getClassLoader().getResourceAsStream("root-ca-cert.pem")) {
                if (caCertStream == null) {
                    throw new RuntimeException("找不到根CA证书文件 root-ca-cert.pem");
                }
                CertificateFactory cf = CertificateFactory.getInstance("X.509");
                rootCACert = (X509Certificate) cf.generateCertificate(caCertStream);
            }

            // 验证客户端证书是否由根CA签署
            try {
                cert.verify(rootCACert.getPublicKey());
                result.addInfo("客户端证书签名验证通过 - 使用根CA公钥验证成功");
            } catch (SignatureException e) {
                result.addError("客户端证书签名验证失败: 证书不是由可信CA签发的");
                return;
            } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchProviderException e) {
                result.addError("客户端证书签名验证失败: 技术错误 - " + e.getMessage());
                return;
            }

            // 验证根CA证书的有效性
            try {
                rootCACert.checkValidity();
                result.addInfo("根CA证书本身有效");
            } catch (CertificateExpiredException | CertificateNotYetValidException e) {
                result.addError("根CA证书无效: " + e.getMessage());
                return;
            }

            /*// 检查根CA序列号
            String rootCASerial = rootCACert.getSerialNumber().toString(16).toUpperCase();
            if (!trustedRootCASerials.contains(rootCASerial)) {
                result.addError("根CA序列号不在信任列表中: " + rootCASerial);
                return;
            }*/

            // 更智能的颁发者信息检查 - 解析DN而不是简单的字符串比较
            String issuerDN = cert.getIssuerX500Principal().getName();
            String rootCAIssuerDN = rootCACert.getSubjectX500Principal().getName();

            // 比较颁发者和根CA的DN字段
            boolean isIssuerValid = compareDistinguishedNames(issuerDN, rootCAIssuerDN);

            if (isIssuerValid) {
                result.addInfo("证书颁发者DN与根CA完全匹配");
            } else {
                result.addWarning("证书颁发者DN与根CA基本匹配但不完全一致");
            }

            result.addInfo("证书链验证通过");

        } catch (Exception e) {
            result.addError("证书链验证失败: " + e.getMessage());
            logger.error("证书链验证异常", e);
        }
    }

    /**
     * 智能比较两个DN是否相等,忽略顺序和格式差异
     */
    private boolean compareDistinguishedNames(String dn1, String dn2) {
        if (dn1 == null || dn2 == null) {
            return false;
        }

        if (dn1.equals(dn2)) {
            return true;
        }

        // 解析DN为字段映射
        Map<String, String> dnFields1 = parseDN(dn1);
        Map<String, String> dnFields2 = parseDN(dn2);

        // 比较字段是否相等
        return dnFields1.equals(dnFields2);
    }

    /**
     * 解析DN字符串为字段映射
     */
    private Map<String, String> parseDN(String dn) {
        Map<String, String> fields = new TreeMap<>(); // 使用TreeMap保持顺序一致

        String[] parts = dn.split(",");
        for (String part : parts) {
            String trimmedPart = part.trim();
            if (trimmedPart.isEmpty()) {
                continue;
            }

            int equalsIndex = trimmedPart.indexOf('=');
            if (equalsIndex != -1) {
                String key = trimmedPart.substring(0, equalsIndex).trim().toUpperCase();
                String value = trimmedPart.substring(equalsIndex + 1).trim();
                fields.put(key, value);
            }
        }

        return fields;
    }

    /**
     * 4. 验证证书组织
     */
    private void validateOrganization(X509Certificate cert, ValidationResult result) {
        String subject = cert.getSubjectX500Principal().getName();
        String organization = extractOrganization(subject);

        if (!allowedOrganizations.contains(organization)) {
            result.addError("证书组织不在允许列表中: " + organization);
            return;
        }

        result.addInfo("证书组织验证通过: " + organization);
    }

    /**
     * 5. 验证证书密钥强度
     */
    private void validateKeyStrength(X509Certificate cert, ValidationResult result) {
        try {
            PublicKey publicKey = cert.getPublicKey();

            if (publicKey instanceof RSAPublicKey) {
                RSAPublicKey rsaPublicKey = (RSAPublicKey) publicKey;
                int keyLength = rsaPublicKey.getModulus().bitLength();

                if (keyLength < 2048) {
                    result.addWarning("RSA密钥长度较短: " + keyLength + "位,建议使用2048位或更高");
                } else if (keyLength >= 4096) {
                    result.addInfo("RSA密钥强度高: " + keyLength + "位");
                } else {
                    result.addInfo("RSA密钥强度适中: " + keyLength + "位");
                }

                result.addInfo("密钥强度验证通过");
            } else {
                result.addWarning("非RSA密钥,使用其他算法: " + publicKey.getAlgorithm());
            }
        } catch (Exception e) {
            result.addError("密钥强度验证失败: " + e.getMessage());
        }
    }

    /**
     * 6. 验证证书扩展
     */
    private void validateExtensions(X509Certificate cert, ValidationResult result) {
        try {
            boolean[] keyUsage = cert.getKeyUsage();
            if (keyUsage != null) {
                List<String> usages = new ArrayList<>();
                if (keyUsage[0]) usages.add("digitalSignature");
                if (keyUsage[1]) usages.add("nonRepudiation");
                if (keyUsage[2]) usages.add("keyEncipherment");
                if (keyUsage[3]) usages.add("dataEncipherment");
                if (keyUsage[4]) usages.add("keyAgreement");
                if (keyUsage[5]) usages.add("keyCertSign");
                if (keyUsage[6]) usages.add("cRLSign");
                if (keyUsage[7]) usages.add("encipherOnly");
                if (keyUsage[8]) usages.add("decipherOnly");

                result.addInfo("密钥用途: " + String.join(", ", usages));
            }

            List<String> extendedKeyUsage = cert.getExtendedKeyUsage();
            if (extendedKeyUsage != null) {
                result.addInfo("扩展密钥用途: " + String.join(", ", extendedKeyUsage));

                if (!extendedKeyUsage.contains("clientAuth")) {
                    result.addWarning("证书缺少客户端认证用途 (clientAuth)");
                }
            }

            result.addInfo("证书扩展验证通过");

        } catch (Exception e) {
            result.addError("证书扩展验证失败: " + e.getMessage());
        }
    }

    /**
     * 计算SHA-256指纹
     */
    private String calculateSHA256Fingerprint(X509Certificate cert) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] derEncoded = cert.getEncoded();
        byte[] digest = md.digest(derEncoded);

        StringBuilder hexString = new StringBuilder();
        for (byte b : digest) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }

        return hexString.toString().toUpperCase();
    }

    /**
     * 从证书主题中提取组织信息
     */
    private String extractOrganization(String subject) {
        Map<String, String> dnFields = parseDN(subject);
        return dnFields.getOrDefault("O", "Unknown");
    }

    /**
     * 从证书主题中提取通用名(CN)
     */
    private String extractCN(String subject) {
        Map<String, String> dnFields = parseDN(subject);
        return dnFields.getOrDefault("CN", "Unknown");
    }

    /**
     * 记录验证结果
     */
    private void logValidationResult(X509Certificate cert, ValidationResult result) {
        try {
            String subject = cert.getSubjectX500Principal().getName();
            String issuer = cert.getIssuerX500Principal().getName();
            String serialNumber = cert.getSerialNumber().toString(16);

            logger.info("证书验证完成 - 主题: {}, 颁发者: {}, 序列号: {}", subject, issuer, serialNumber);
            logger.info("验证结果: {}", result.isValid() ? "通过" : "失败");

            if (result.hasErrors()) {
                logger.error("证书验证错误: {}", result.getErrors());
            }

            if (result.hasWarnings()) {
                logger.warn("证书验证警告: {}", result.getWarnings());
            }

        } catch (Exception e) {
            logger.error("记录验证结果失败", e);
        }
    }

测试验证

bash 复制代码
# 测试公共接口(无需客户端证书)
curl -k https://localhost:8443/public/info

# 测试需要认证的接口(带客户端证书)
curl -k --cert client.p12:changeit --cacert ca-cert.pem \
  https://localhost:8443/secure/data

# 获取证书信息
curl -k --cert client.p12:changeit --cacert ca-cert.pem \
  https://localhost:8443/api/certificate-info

总结

Spring Boot实现HTTPS双向认证虽然配置相对复杂,但能提供显著的安全提升。

在实际项目中,建议结合业务需求和安全等级要求来选择合适的认证方案。对于高安全级别的系统,双向认证是一个值得考虑的选择。

复制代码
https://github.com/yuboon/java-examples/tree/master/springboot-mutual-cert
相关推荐
是一个Bug1 小时前
Spring Boot 的全局异常处理器
spring boot·后端·python
嘻哈baby1 小时前
Prometheus + Grafana 监控系统搭建实战:从零到生产可用
后端
5***84641 小时前
【SpringBoot3】Spring Boot 3.0 集成 Mybatis Plus
spring boot·后端·mybatis
Java水解1 小时前
MySQL - 一文理清存储引擎:InnoDB vs MyISAM 核心差异
后端
青春不流名1 小时前
Java List初始化的例子
java·windows·list
4***17271 小时前
【MySQL篇】使用Java操作MySQL实现数据交互
java·mysql·交互
sheji34161 小时前
【开题答辩全过程】以 基于Spring Boot的流浪动物救助系统设计为例,包含答辩的问题和答案
java·spring boot·后端
今天也很困1 小时前
用户密码安全存储:Go 实现 SM3 哈希加盐
后端
a***81391 小时前
SpringBoot集成Prometheus
spring boot·后端·prometheus