HTTPS 双向认证与 USB 加密锁配置实战
HTTPS 双向认证与 USB 加密锁配置实战
HTTPS 单向认证只验证服务端身份,双向认证(mTLS)要求客户端也提供证书,服务端会验证客户端的合法性。
本文基于真实项目,使用 Nginx + Docker 实现 mTLS,并将客户端证书写入 USB 硬件加密锁(ET199),实现"不插锁就无法访问"的硬件级访问控制。
整体流程:
- 搭建本地 CA,签发服务端和客户端证书
- 配置 Nginx 开启 mTLS,Docker 容器化部署
- 将客户端证书导入 USB 加密锁
- 验证效果
环境说明
| 组件 | 说明 |
|---|---|
| Web 服务器 | Nginx (Alpine Docker 镜像) |
| 证书工具 | OpenSSL |
| 加密锁 | ET199(PKI 模式,约 30 元) |
| 部署方式 | Docker Compose |
项目结构
et199/
├── certs/
│ ├── generate_ca.sh # 生成根 CA 脚本
│ ├── generate_certs.sh # 生成服务端/客户端证书脚本
│ ├── openssl_ca.cnf # OpenSSL 配置文件
│ ├── nginx.conf # Nginx mTLS 配置
│ ├── demoCA/ # CA 证书存储
│ ├── server.crt / server.key
│ ├── server.key.unsecure # 无密码私钥(供 Nginx 使用)
│ ├── client.crt / client.key
│ └── client.p12 # 导入浏览器/加密锁用
├── html/
│ ├── index.html
│ └── 403.html
├── Dockerfile
├── docker-compose.yml
└── test.sh第一步:OpenSSL 配置文件
所有证书操作都依赖这份配置,先把它准备好,放在 certs/openssl_ca.cnf:
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ./demoCA
certs = $dir/certs
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
RANDFILE = $dir/private/.rand
private_key = $dir/private/cakey.pem
certificate = $dir/cacert.pem
default_days = 3650
default_crl_days = 30
default_md = sha256
preserve = no
policy = policy_match
[ policy_match ]
countryName = match
stateOrProvinceName = match
organizationName = match
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 2048
default_keyfile = privkey.pem
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
[ req_distinguished_name ]
countryName_default = CN
stateOrProvinceName_default = Guangdong
localityName_default = Shenzhen
0.organizationName_default = Technology Co., Ltd.
organizationalUnitName_default = IT Department
commonName_default = example.com
emailAddress_default = admin@example.com
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer:always
basicConstraints = CA:true
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = example.com
DNS.2 = *.example.com第二步:生成根 CA
certs/generate_ca.sh — 只需运行一次,CA 是签发所有证书的根基:
#!/bin/bash
set -e
CERT_DIR="./certs"
PASSWORD="111111"
CERT_DAYS=3650
cd "$CERT_DIR"
# 初始化 CA 目录结构
mkdir -p demoCA/newcerts demoCA/private
touch demoCA/index.txt
echo "1000" > demoCA/serial
echo "unique_subject = no" > demoCA/index.txt.attr
# 生成 CA 私钥(2048 位,带密码保护)
openssl genrsa -des3 -passout pass:"$PASSWORD" -out demoCA/private/cakey.pem 2048
# 自签名 CA 证书,有效期 10 年
openssl req -new -x509 -days $CERT_DAYS \
-key demoCA/private/cakey.pem \
-passin pass:"$PASSWORD" \
-out demoCA/cacert.pem \
-config openssl_ca.cnf <<EOF
CN
Guangdong
Shenzhen
Technology Co., Ltd.
IT Department
Root CA
admin@example.com
EOF
echo "CA 证书生成完成"
openssl x509 -in demoCA/cacert.pem -noout -subject -dates运行:
bash certs/generate_ca.sh第三步:签发服务端和客户端证书
certs/generate_certs.sh — 支持传入域名参数,一键生成所有需要的证书:
#!/bin/bash
set -e
CERT_DIR="./certs"
DOMAIN="${1:-example.com}"
PASSWORD="111111"
CERT_DAYS=3650
cd "$CERT_DIR"
# 检查 CA 是否存在
if [ ! -f "demoCA/private/cakey.pem" ] || [ ! -f "demoCA/cacert.pem" ]; then
echo "错误: CA 证书不存在,请先运行 generate_ca.sh"
exit 1
fi
WILDCARD_DOMAIN="*.${DOMAIN#*.}"
# 用时间戳做序列号,避免重复签发时冲突
echo "$(date +%s)" > demoCA/serial
echo "unique_subject = no" > demoCA/index.txt.attr
# 动态生成包含 SAN 的 openssl 配置
cat > openssl_ca.cnf <<CONFEOF
# ... (同上,将 alt_names 中的域名替换为 $DOMAIN 和 $WILDCARD_DOMAIN)
CONFEOF
# ---- 服务端证书 ----
openssl genrsa -des3 -passout pass:"$PASSWORD" -out server.key 2048
openssl req -new -key server.key -passin pass:"$PASSWORD" \
-out server.csr -config openssl_ca.cnf <<EOF
CN
Guangdong
Shenzhen
Technology Co., Ltd.
IT Department
$DOMAIN
admin@example.com
$PASSWORD
EOF
openssl ca -in server.csr -out server.crt -config openssl_ca.cnf \
-extensions v3_req -days $CERT_DAYS \
-passin pass:"$PASSWORD" -batch -notext
# Nginx 启动不需要输密码,剥离私钥密码
openssl rsa -in server.key -passin pass:"$PASSWORD" -out server.key.unsecure
# ---- 客户端证书 ----
openssl genrsa -des3 -passout pass:"$PASSWORD" -out client.key 2048
openssl req -new -key client.key -passin pass:"$PASSWORD" \
-out client.csr -config openssl_ca.cnf <<EOF
CN
Guangdong
Shenzhen
Technology Co., Ltd.
IT Department
$DOMAIN
admin@example.com
$PASSWORD
EOF
openssl ca -in client.csr -out client.crt -config openssl_ca.cnf \
-days $CERT_DAYS -passin pass:"$PASSWORD" -batch -notext
# 导出 .p12 格式,用于浏览器和加密锁导入
openssl pkcs12 -export -clcerts \
-in client.crt -inkey client.key \
-out client.p12 \
-passin pass:"$PASSWORD" -passout pass:"$PASSWORD"
# 同样生成无密码客户端私钥,方便 curl 测试
openssl rsa -in client.key -passin pass:"$PASSWORD" -out client.key.unsecure
echo "证书生成完成,所有密码: $PASSWORD"运行:
# 使用自定义域名
bash certs/generate_certs.sh example.com生成后的文件:
certs/
├── demoCA/
│ ├── cacert.pem # CA 根证书(公钥,分发给客户端信任)
│ └── private/cakey.pem # CA 私钥(严格保管,不对外)
├── server.crt # 服务端证书
├── server.key # 服务端私钥(带密码)
├── server.key.unsecure # 服务端私钥(无密码,供 Nginx 使用)
├── client.crt # 客户端证书
├── client.key # 客户端私钥(带密码)
├── client.key.unsecure # 客户端私钥(无密码,供 curl 测试)
└── client.p12 # 客户端证书包(导入浏览器/加密锁)遇到
failed to update database / TXT_DB error number 2,执行:echo "unique_subject = no" > demoCA/index.txt.attr
第四步:配置 Nginx mTLS
certs/nginx.conf — 核心在于 ssl_verify_client 和 ssl_client_certificate:
# 用 map 判断证书验证结果,未通过则重定向到 403 页面
map $ssl_client_verify $cert_check {
default /403.html;
SUCCESS "";
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
root /usr/share/nginx/html;
index index.html;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# 服务端证书
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# 用于验证客户端证书的 CA
ssl_client_certificate /etc/nginx/ssl/ca.crt;
# optional: 有证书就验证,没有证书也放行(由 map 决定后续行为)
# require: 强制要求客户端提供证书,否则直接 400
ssl_verify_client optional;
ssl_verify_depth 10;
ssl_protocols TLSv1.2;
ssl_ciphers HIGH:!aNULL:!MD5:!RC4;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
# 403 页面只允许内部跳转
location = /403.html {
internal;
}
# 反向代理后端(可选)
location @backend {
proxy_pass http://host.docker.internal:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 主路由:证书未通过则返回 403,通过则转发后端
location / {
try_files $cert_check @backend;
}
# 调试接口:查看客户端证书信息
location /cert-info {
default_type text/plain;
return 200 "Client DN: $ssl_client_s_dn\nVerify: $ssl_client_verify\n";
}
}ssl_verify_client optional 配合 map 的好处是:没有证书时可以返回自定义的 403 页面,而不是浏览器原生的 SSL 握手错误,用户体验更好。如果想要更严格的控制,改成 ssl_verify_client require 即可。
第五步:Docker 容器化部署
Dockerfile:
FROM nginx:alpine
RUN mkdir -p /etc/nginx/ssl /usr/share/nginx/html
# 证书和配置直接打包进镜像
COPY certs/nginx.conf /etc/nginx/conf.d/default.conf
COPY certs/server.crt /etc/nginx/ssl/server.crt
COPY certs/server.key.unsecure /etc/nginx/ssl/server.key
COPY certs/demoCA/cacert.pem /etc/nginx/ssl/ca.crt
COPY html/ /usr/share/nginx/html/
EXPOSE 443
CMD ["nginx", "-g", "daemon off;"]docker-compose.yml:
services:
nginx:
build: .
container_name: nginx-mtls
ports:
- "4433:443"
extra_hosts:
- "host.docker.internal:host-gateway" # 容器内访问宿主机
restart: unless-stopped启动:
docker compose build
docker compose up -d第六步:验证 mTLS 是否生效
test.sh — 覆盖了有证书、无证书、PEM 格式、P12 格式四种场景:
#!/bin/bash
DOMAIN="example.com"
CERT_DIR="./certs"
BASE_URL="https://127.0.0.1:4433"
echo "=========================================="
echo "HTTPS 双向认证测试"
echo "=========================================="
# 测试1:PEM 格式证书访问主页(应返回 200)
echo ""
echo "[1] PEM 证书访问主页"
HTTP_CODE=$(curl -k -s -o /dev/null -w "%{http_code}" \
--cert "$CERT_DIR/client.crt" \
--key "$CERT_DIR/client.key.unsecure" \
--cacert "$CERT_DIR/demoCA/cacert.pem" \
-H "Host: $DOMAIN" \
"$BASE_URL/")
echo " HTTP 状态码: $HTTP_CODE (预期 200)"
# 测试2:查看证书信息
echo ""
echo "[2] 查看客户端证书信息"
curl -k -s \
--cert "$CERT_DIR/client.crt" \
--key "$CERT_DIR/client.key.unsecure" \
--cacert "$CERT_DIR/demoCA/cacert.pem" \
-H "Host: $DOMAIN" \
"$BASE_URL/cert-info"
# 测试3:无证书访问(应显示 403 页面)
echo ""
echo "[3] 无证书访问(应显示 Access Denied)"
RESULT=$(curl -k -s \
--cacert "$CERT_DIR/demoCA/cacert.pem" \
-H "Host: $DOMAIN" \
"$BASE_URL/")
if echo "$RESULT" | grep -q "Access Denied"; then
echo " ✓ 正确返回 403 禁止访问"
else
echo " ✗ 未返回预期的 403 页面"
fi
# 测试4:P12 格式证书(加密锁导出的格式)
echo ""
echo "[4] P12 证书访问主页"
HTTP_CODE=$(curl -k -s -o /dev/null -w "%{http_code}" \
--cert "$CERT_DIR/client.p12:111111" \
--cert-type P12 \
--cacert "$CERT_DIR/demoCA/cacert.pem" \
-H "Host: $DOMAIN" \
"$BASE_URL/")
echo " HTTP 状态码: $HTTP_CODE (预期 200)"运行测试:
bash test.sh预期输出:
[1] PEM 证书访问主页
HTTP 状态码: 200 (预期 200)
[2] 查看客户端证书信息
Client DN: CN=example.com,OU=IT Department,O=Technology Co.\, Ltd.,ST=Guangdong,C=CN
Verify: SUCCESS
[3] 无证书访问(应显示 Access Denied)
✓ 正确返回 403 禁止访问
[4] P12 证书访问主页
HTTP 状态码: 200 (预期 200)第七步:将客户端证书写入 USB 加密锁
加密锁(ET199)需要提前通过管理软件初始化为 PKI 模式。
- 将
client.p12传输到 Windows 机器(Linux 下可用sz client.p12) - 打开加密锁管理软件,输入 PIN 码登录
- 点击"导入",选择
client.p12,输入导出密码(111111) - 导入成功后,证书和私钥存储在加密锁的安全芯片中,私钥不可导出
插入加密锁访问站点,浏览器弹出证书选择框,选择加密锁中的证书并输入 PIN 码即可正常访问。拔出加密锁后,服务端拒绝连接。
403 页面
html/403.html — 无证书访问时展示的页面,比浏览器原生 SSL 错误更友好:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Access Denied</title>
</head>
<body>
<h1>Access Denied</h1>
<p>This website requires a client certificate for authentication.</p>
<p>Please use a valid client certificate to access.</p>
<hr>
<p>Error: 403 Forbidden</p>
</body>
</html>关键配置说明
| 配置项 | 说明 |
|---|---|
ssl_verify_client require | 强制要求客户端证书,无证书直接 400 |
ssl_verify_client optional | 有证书就验证,无证书也放行,配合 map 做软拦截 |
ssl_verify_depth 10 | 证书链最大验证深度,自签 CA 设 1 即可 |
ssl_client_certificate | 指定信任的 CA,只有该 CA 签发的客户端证书才被接受 |
$ssl_client_verify | Nginx 变量,值为 SUCCESS / FAILED / NONE |
$ssl_client_s_dn | 客户端证书的 DN 信息,可用于日志或后端鉴权 |
为什么用加密锁而不是直接导入浏览器
直接把 .p12 导入浏览器的问题在于:证书文件可以被复制和分发,一旦泄露就失去了访问控制的意义。
加密锁的私钥存储在硬件安全芯片中,私钥不可导出,所有加密运算在锁内完成。即使有人拿到了锁,还需要知道 PIN 码才能使用,实现了"持有硬件 + 知道密码"的双因素认证。
ET199 这类 PKI 加密锁价格不高,加上运费大概 30 元左右,适合对安全性有一定要求的内网管理系统。
更新日志
9a989-于