前言
为什么需要双向认证?
传统的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