Skip to content

RabbitMQ 客户端证书验证

概述

客户端证书验证是 mTLS 的核心组成部分,通过验证客户端证书来确认客户端身份的合法性。本文档详细介绍 RabbitMQ 客户端证书验证的配置方法、验证流程和最佳实践。

核心知识点

客户端证书验证流程

┌─────────────────────────────────────────────────────────────┐
│                  客户端证书验证流程                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. TLS 握手阶段                                            │
│     ├── 客户端发起连接                                      │
│     ├── 服务器发送证书请求                                   │
│     ├── 客户端发送证书链                                     │
│     └── 服务器验证证书有效性                                 │
│                                                             │
│  2. 证书验证阶段                                            │
│     ├── 验证证书签名                                        │
│     ├── 验证证书有效期                                      │
│     ├── 验证证书用途 (clientAuth)                           │
│     ├── 验证证书链完整性                                     │
│     └── 检查证书撤销状态                                     │
│                                                             │
│  3. 身份映射阶段                                            │
│     ├── 从证书提取用户名                                     │
│     ├── 查找用户权限                                        │
│     └── 建立连接                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

验证级别配置

验证级别配置值说明
不验证verify_none不验证客户端证书(不安全)
验证证书verify_peer验证客户端证书有效性
强制证书verify_peer + fail_if_no_peer_cert必须提供有效证书

用户名提取方式

┌─────────────────────────────────────────────────────────────┐
│                    用户名提取方式                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Common Name (CN)                                        │
│     ssl_cert_login_from = common_name                       │
│     证书 CN: client.example.com                             │
│     用户名: client.example.com                              │
│                                                             │
│  2. Subject DN                                              │
│     ssl_cert_login_from = subject                           │
│     证书主题: CN=client,O=Company,C=CN                      │
│     用户名: CN=client,O=Company,C=CN                        │
│                                                             │
│  3. Subject Alternative Name (SAN)                          │
│     ssl_cert_login_from = san                               │
│     ssl_cert_login_san_type = dns                           │
│     SAN DNS: client.example.com                             │
│     用户名: client.example.com                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

配置示例

基础客户端证书验证

conf
# /etc/rabbitmq/rabbitmq.conf

# TLS 监听器
listeners.ssl.default = 5671

# 服务器证书
ssl_options.cacertfile = /etc/rabbitmq/ssl/ca_certificate.pem
ssl_options.certfile   = /etc/rabbitmq/ssl/server_certificate.pem
ssl_options.keyfile    = /etc/rabbitmq/ssl/server_key.pem

# 客户端证书验证
ssl_options.verify     = verify_peer
ssl_options.fail_if_no_peer_cert = true

# 用户名提取
ssl_cert_login_from = common_name

高级验证配置

conf
# 证书链深度
ssl_options.depth = 2

# 证书撤销列表
ssl_options.crl_check = peer
ssl_options.crl_file = /etc/rabbitmq/ssl/crl.pem

# 证书用途验证
ssl_options.verify_fun = {ssl_verify_client, []}

# SAN 类型配置
ssl_cert_login_from = san
ssl_cert_login_san_type = dns
ssl_cert_login_san_index = 1

混合认证配置

conf
# 同时支持证书认证和用户名密码认证
auth_mechanisms.1 = EXTERNAL
auth_mechanisms.2 = PLAIN
auth_mechanisms.3 = AMQPLAIN

# 证书验证(可选)
ssl_options.verify     = verify_peer
ssl_options.fail_if_no_peer_cert = false

# 证书用户名提取
ssl_cert_login_from = common_name

证书认证插件配置

bash
# 启用证书认证插件
rabbitmq-plugins enable rabbitmq_auth_mechanism_ssl

# 配置文件
# /etc/rabbitmq/rabbitmq.conf
auth_mechanisms.1 = EXTERNAL
ssl_cert_login_from = common_name
ssl_options.verify = verify_peer
ssl_options.fail_if_no_peer_cert = true

PHP 代码示例

客户端证书连接类

php
<?php

use PhpAmqpLib\Connection\AMQPSSLConnection;

class ClientCertConnection
{
    private $config;

    public function __construct(array $config)
    {
        $this->config = $config;
    }

    public function connect(): AMQPSSLConnection
    {
        $sslOptions = $this->buildSSLOptions();

        return new AMQPSSLConnection(
            $this->config['host'],
            $this->config['port'] ?? 5671,
            '',
            '',
            $this->config['vhost'] ?? '/',
            $sslOptions,
            $this->config['options'] ?? []
        );
    }

    private function buildSSLOptions(): array
    {
        $options = [
            'cafile' => $this->config['ca_cert'],
            'verify_peer' => true,
            'verify_peer_name' => true,
            'allow_self_signed' => $this->config['allow_self_signed'] ?? false
        ];

        if (isset($this->config['client_cert'])) {
            $options['local_cert'] = $this->config['client_cert'];
        }

        if (isset($this->config['client_key'])) {
            $options['local_pk'] = $this->config['client_key'];
        }

        if (isset($this->config['passphrase'])) {
            $options['passphrase'] = $this->config['passphrase'];
        }

        if (isset($this->config['ciphers'])) {
            $options['ciphers'] = $this->config['ciphers'];
        }

        return $options;
    }

    public function getCertificateInfo(): array
    {
        if (!isset($this->config['client_cert'])) {
            return ['error' => '未配置客户端证书'];
        }

        $content = file_get_contents($this->config['client_cert']);
        $cert = openssl_x509_read($content);

        if ($cert === false) {
            return ['error' => '无法解析证书'];
        }

        $info = openssl_x509_parse($cert);

        return [
            'subject' => $info['subject'],
            'issuer' => $info['issuer'],
            'valid_from' => date('Y-m-d H:i:s', $info['validFrom_time_t']),
            'valid_to' => date('Y-m-d H:i:s', $info['validTo_time_t']),
            'serial_number' => $info['serialNumber'],
            'fingerprint' => openssl_x509_fingerprint($cert, 'sha256')
        ];
    }
}

// 使用示例
$connection = new ClientCertConnection([
    'host' => 'rabbitmq.example.com',
    'port' => 5671,
    'vhost' => '/',
    'ca_cert' => '/path/to/ca_certificate.pem',
    'client_cert' => '/path/to/client_certificate.pem',
    'client_key' => '/path/to/client_key.pem',
    'passphrase' => 'optional_passphrase',
    'ciphers' => 'TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256'
]);

$conn = $connection->connect();
echo "客户端证书认证连接成功\n";

$certInfo = $connection->getCertificateInfo();
print_r($certInfo);

证书用户管理服务

php
<?php

class CertificateUserService
{
    private $apiUrl;
    private $credentials;

    public function __construct(string $host, int $port, string $user, string $password)
    {
        $this->apiUrl = "http://{$host}:{$port}/api";
        $this->credentials = [$user, $password];
    }

    public function createCertificateUser(string $commonName, array $permissions = []): bool
    {
        $username = $this->normalizeUsername($commonName);

        $response = $this->httpRequest(
            "{$this->apiUrl}/users/{$username}",
            'PUT',
            [
                'password' => '',
                'tags' => ''
            ]
        );

        if ($response['status'] !== 204) {
            return false;
        }

        if (!empty($permissions)) {
            return $this->setPermissions($username, $permissions);
        }

        return true;
    }

    public function setPermissions(string $username, array $permissions): bool
    {
        $vhost = urlencode($permissions['vhost'] ?? '/');
        $url = "{$this->apiUrl}/permissions/{$vhost}/{$username}";

        $response = $this->httpRequest($url, 'PUT', [
            'configure' => $permissions['configure'] ?? '.*',
            'write' => $permissions['write'] ?? '.*',
            'read' => $permissions['read'] ?? '.*'
        ]);

        return $response['status'] === 204;
    }

    public function deleteCertificateUser(string $commonName): bool
    {
        $username = $this->normalizeUsername($commonName);

        $response = $this->httpRequest(
            "{$this->apiUrl}/users/{$username}",
            'DELETE'
        );

        return $response['status'] === 204;
    }

    public function listCertificateUsers(): array
    {
        $response = $this->httpRequest("{$this->apiUrl}/users", 'GET');

        if ($response['status'] !== 200) {
            return [];
        }

        $users = json_decode($response['body'], true) ?: [];

        return array_filter($users, function ($user) {
            return empty($user['password_hash']) || $user['password_hash'] === '';
        });
    }

    public function getUserPermissions(string $commonName): array
    {
        $username = $this->normalizeUsername($commonName);

        $response = $this->httpRequest(
            "{$this->apiUrl}/users/{$username}/permissions",
            'GET'
        );

        if ($response['status'] !== 200) {
            return [];
        }

        return json_decode($response['body'], true) ?: [];
    }

    private function normalizeUsername(string $commonName): string
    {
        return strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '_', $commonName));
    }

    private function httpRequest(string $url, string $method, array $data = null): array
    {
        $ch = curl_init($url);

        $options = [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_USERPWD => implode(':', $this->credentials),
            CURLOPT_HTTPHEADER => ['Content-Type: application/json']
        ];

        if ($data !== null) {
            $options[CURLOPT_POSTFIELDS] = json_encode($data);
        }

        curl_setopt_array($ch, $options);

        $body = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return ['status' => $status, 'body' => $body];
    }
}

// 使用示例
$service = new CertificateUserService('localhost', 15672, 'admin', 'password');

// 创建证书用户
$service->createCertificateUser('client.example.com', [
    'vhost' => '/',
    'configure' => '^client_.*',
    'write' => '^client_.*',
    'read' => '^client_.*'
]);

// 列出所有证书用户
$users = $service->listCertificateUsers();
print_r($users);

证书验证服务

php
<?php

class ClientCertificateValidator
{
    private $caCertPath;

    public function __construct(string $caCertPath)
    {
        $this->caCertPath = $caCertPath;
    }

    public function validate(string $certPath, string $keyPath = null): array
    {
        $results = [
            'valid' => false,
            'checks' => []
        ];

        $results['checks']['exists'] = $this->checkExists($certPath);
        $results['checks']['parseable'] = $this->checkParseable($certPath);
        $results['checks']['validity_period'] = $this->checkValidityPeriod($certPath);
        $results['checks']['chain'] = $this->checkChain($certPath);
        $results['checks']['purpose'] = $this->checkClientAuthPurpose($certPath);

        if ($keyPath) {
            $results['checks']['key_match'] = $this->checkKeyMatch($certPath, $keyPath);
        }

        $results['valid'] = !in_array(false, array_column($results['checks'], 'passed'), true);

        return $results;
    }

    private function checkExists(string $path): array
    {
        return [
            'passed' => file_exists($path),
            'message' => file_exists($path) ? '文件存在' : '文件不存在'
        ];
    }

    private function checkParseable(string $path): array
    {
        $content = file_get_contents($path);
        $cert = @openssl_x509_read($content);

        return [
            'passed' => $cert !== false,
            'message' => $cert !== false ? '证书格式正确' : '无法解析证书'
        ];
    }

    private function checkValidityPeriod(string $path): array
    {
        $content = file_get_contents($path);
        $cert = openssl_x509_read($content);
        $info = openssl_x509_parse($cert);

        $now = time();
        $isExpired = $info['validTo_time_t'] < $now;
        $notYetValid = $info['validFrom_time_t'] > $now;

        $daysRemaining = floor(($info['validTo_time_t'] - $now) / 86400);

        return [
            'passed' => !$isExpired && !$notYetValid,
            'message' => $isExpired ? '证书已过期' :
                        ($notYetValid ? '证书尚未生效' : "证书有效,剩余 {$daysRemaining} 天"),
            'days_remaining' => max(0, $daysRemaining)
        ];
    }

    private function checkChain(string $path): array
    {
        $cert = file_get_contents($path);
        $ca = file_get_contents($this->caCertPath);

        $certResource = openssl_x509_read($cert);
        $result = @openssl_x509_checkpurpose($certResource, X509_PURPOSE_ANY, [$ca]);

        return [
            'passed' => $result === true,
            'message' => $result === true ? '证书链验证通过' : '证书链验证失败'
        ];
    }

    private function checkClientAuthPurpose(string $path): array
    {
        $cert = file_get_contents($path);
        $certResource = openssl_x509_read($cert);

        $result = @openssl_x509_checkpurpose($certResource, X509_PURPOSE_SSL_CLIENT);

        return [
            'passed' => $result === true,
            'message' => $result === true ? '证书可用于客户端认证' : '证书不可用于客户端认证'
        ];
    }

    private function checkKeyMatch(string $certPath, string $keyPath): array
    {
        $cert = file_get_contents($certPath);
        $key = file_get_contents($keyPath);

        $certResource = openssl_x509_read($cert);
        $keyResource = openssl_pkey_get_private($key);

        $match = @openssl_x509_check_private_key($certResource, $keyResource);

        return [
            'passed' => $match,
            'message' => $match ? '证书与私钥匹配' : '证书与私钥不匹配'
        ];
    }

    public function extractUsername(string $certPath, string $method = 'cn'): ?string
    {
        $content = file_get_contents($certPath);
        $cert = openssl_x509_read($content);
        $info = openssl_x509_parse($cert);

        switch ($method) {
            case 'cn':
                return $info['subject']['CN'] ?? null;
            case 'subject':
                return $this->formatDN($info['subject']);
            case 'email':
                return $info['subject']['emailAddress'] ?? null;
            default:
                return null;
        }
    }

    private function formatDN(array $dn): string
    {
        $parts = [];
        foreach ($dn as $key => $value) {
            $parts[] = "{$key}={$value}";
        }
        return implode(',', $parts);
    }
}

// 使用示例
$validator = new ClientCertificateValidator('/path/to/ca_certificate.pem');

$result = $validator->validate(
    '/path/to/client_certificate.pem',
    '/path/to/client_key.pem'
);

if ($result['valid']) {
    echo "客户端证书验证通过\n";
    $username = $validator->extractUsername('/path/to/client_certificate.pem');
    echo "提取的用户名: {$username}\n";
} else {
    echo "客户端证书验证失败\n";
    print_r($result['checks']);
}

证书撤销检查

php
<?php

class CertificateRevocationChecker
{
    private $crlPath;
    private $caCertPath;

    public function __construct(string $crlPath, string $caCertPath)
    {
        $this->crlPath = $crlPath;
        $this->caCertPath = $caCertPath;
    }

    public function isRevoked(string $certPath): bool
    {
        if (!file_exists($this->crlPath)) {
            return false;
        }

        $cert = file_get_contents($certPath);
        $certResource = openssl_x509_read($cert);
        $certInfo = openssl_x509_parse($certResource);

        $crl = file_get_contents($this->crlPath);
        $crlResource = openssl_crl_read($crl);

        if ($crlResource === false) {
            return false;
        }

        $crlInfo = openssl_crl_parse($crlResource);

        $serialNumber = $certInfo['serialNumber'];
        $serialNumberHex = strtoupper(ltrim($serialNumber, '0x'));

        return $this->checkSerialInCRL($serialNumberHex, $crlInfo);
    }

    private function checkSerialInCRL(string $serial, array $crlInfo): bool
    {
        if (!isset($crlInfo['revoked'])) {
            return false;
        }

        foreach ($crlInfo['revoked'] as $revoked) {
            if (strtoupper($revoked['serial']) === $serial) {
                return true;
            }
        }

        return false;
    }

    public function getCRLInfo(): array
    {
        if (!file_exists($this->crlPath)) {
            return ['error' => 'CRL 文件不存在'];
        }

        $crl = file_get_contents($this->crlPath);
        $crlResource = openssl_crl_read($crl);

        if ($crlResource === false) {
            return ['error' => '无法解析 CRL'];
        }

        $info = openssl_crl_parse($crlResource);

        return [
            'issuer' => $info['issuer'] ?? null,
            'last_update' => isset($info['lastUpdate']) ? date('Y-m-d H:i:s', $info['lastUpdate']) : null,
            'next_update' => isset($info['nextUpdate']) ? date('Y-m-d H:i:s', $info['nextUpdate']) : null,
            'revoked_count' => count($info['revoked'] ?? [])
        ];
    }
}

实际应用场景

场景一:服务间证书认证

php
<?php

class ServiceCertificateAuth
{
    private $serviceName;
    private $certConfig;

    public function __construct(string $serviceName, array $certConfig)
    {
        $this->serviceName = $serviceName;
        $this->certConfig = $certConfig;
    }

    public function authenticate(): ?AMQPSSLConnection
    {
        $validator = new ClientCertificateValidator($this->certConfig['ca_cert']);

        $validation = $validator->validate(
            $this->certConfig['client_cert'],
            $this->certConfig['client_key']
        );

        if (!$validation['valid']) {
            throw new RuntimeException("服务证书验证失败: " . json_encode($validation['checks']));
        }

        $connection = new ClientCertConnection($this->certConfig);
        return $connection->connect();
    }

    public function getServiceIdentity(): array
    {
        $validator = new ClientCertificateValidator($this->certConfig['ca_cert']);

        return [
            'service_name' => $this->serviceName,
            'certificate_cn' => $validator->extractUsername($this->certConfig['client_cert']),
            'certificate_valid' => $validator->validate($this->certConfig['client_cert'])['valid']
        ];
    }
}

场景二:证书自动注册

php
<?php

class CertificateAutoRegistration
{
    private $userService;
    private $validator;

    public function __construct(CertificateUserService $userService, ClientCertificateValidator $validator)
    {
        $this->userService = $userService;
        $this->validator = $validator;
    }

    public function registerCertificate(string $certPath, array $permissions = []): array
    {
        $validation = $this->validator->validate($certPath);

        if (!$validation['valid']) {
            return [
                'success' => false,
                'error' => '证书验证失败',
                'details' => $validation['checks']
            ];
        }

        $username = $this->validator->extractUsername($certPath);

        if (!$username) {
            return [
                'success' => false,
                'error' => '无法从证书提取用户名'
            ];
        }

        $created = $this->userService->createCertificateUser($username, $permissions);

        return [
            'success' => $created,
            'username' => $username,
            'message' => $created ? '证书用户注册成功' : '证书用户注册失败'
        ];
    }
}

常见问题与解决方案

问题 1:证书用户不存在

错误信息

ACCESS_REFUSED - Login was refused using authentication mechanism EXTERNAL

解决方案

bash
# 创建证书对应的用户
rabbitmqctl add_user client_cn ""

# 设置权限
rabbitmqctl set_permissions -p / client_cn ".*" ".*" ".*"

问题 2:证书用途错误

错误信息

Certificate purpose does not match

解决方案

bash
# 检查证书扩展用途
openssl x509 -in client_certificate.pem -noout -text | grep -A1 "Extended Key Usage"

# 重新生成证书时添加 clientAuth 用途
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key \
  -CAcreateserial -out client_certificate.pem -days 365 \
  -extfile <(echo "extendedKeyUsage=clientAuth")

问题 3:证书链验证失败

解决方案

bash
# 检查证书链
openssl verify -CAfile ca_certificate.pem client_certificate.pem

# 如果有中间证书,合并验证
cat intermediate.pem ca.pem > full_chain.pem
openssl verify -CAfile full_chain.pem client_certificate.pem

最佳实践建议

1. 证书权限模板

php
<?php

class CertificatePermissionTemplates
{
    const TEMPLATES = [
        'producer' => [
            'configure' => '^$',
            'write' => '.*',
            'read' => '^$'
        ],
        'consumer' => [
            'configure' => '^$',
            'write' => '^$',
            'read' => '.*'
        ],
        'full_access' => [
            'configure' => '.*',
            'write' => '.*',
            'read' => '.*'
        ],
        'isolated' => [
            'configure' => '^{{prefix}}_.*',
            'write' => '^{{prefix}}_.*',
            'read' => '^{{prefix}}_.*'
        ]
    ];

    public static function applyTemplate(string $template, array $variables = []): array
    {
        $permissions = self::TEMPLATES[$template] ?? self::TEMPLATES['isolated'];

        foreach ($permissions as $key => $value) {
            foreach ($variables as $var => $val) {
                $permissions[$key] = str_replace('{{' . $var . '}}', $val, $permissions[$key]);
            }
        }

        return $permissions;
    }
}

2. 证书监控脚本

php
<?php

class CertificateMonitor
{
    private $certDir;
    private $warningDays;

    public function checkAll(): array
    {
        $certificates = glob($this->certDir . '/*_certificate.pem');
        $results = [];

        foreach ($certificates as $certPath) {
            $name = basename($certPath, '_certificate.pem');
            $results[$name] = $this->checkCertificate($certPath);
        }

        return $results;
    }

    private function checkCertificate(string $path): array
    {
        $content = file_get_contents($path);
        $cert = openssl_x509_read($content);
        $info = openssl_x509_parse($cert);

        $now = time();
        $daysRemaining = floor(($info['validTo_time_t'] - $now) / 86400);

        return [
            'valid' => $info['validTo_time_t'] > $now,
            'days_remaining' => $daysRemaining,
            'needs_renewal' => $daysRemaining <= $this->warningDays,
            'expires_at' => date('Y-m-d H:i:s', $info['validTo_time_t']),
            'subject_cn' => $info['subject']['CN'] ?? 'Unknown'
        ];
    }
}

安全注意事项

重要警告

  1. 强制证书验证:生产环境必须设置 fail_if_no_peer_cert = true
  2. 证书用途验证:确保证书包含 clientAuth 扩展用途
  3. 证书撤销机制:配置 CRL 或 OCSP 进行证书撤销检查
  4. 定期审计:检查证书用户权限是否合理
  5. 证书有效期监控:设置告警监控证书过期

相关链接