惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Security Blog
Microsoft Security Blog
Jina AI
Jina AI
量子位
博客园 - 叶小钗
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
IT之家
IT之家
S
SegmentFault 最新的问题
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
小众软件
小众软件
Hugging Face - Blog
Hugging Face - Blog
雷峰网
雷峰网
博客园 - 聂微东
美团技术团队
Last Week in AI
Last Week in AI
罗磊的独立博客
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 三生石上(FineUI控件)
WordPress大学
WordPress大学
宝玉的分享
宝玉的分享
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园_首页
V
Visual Studio Blog
大猫的无限游戏
大猫的无限游戏
The Cloudflare Blog

运维开发绿皮书

OBS虚拟摄像头重命名 Ubuntu备份为LiveOS 使用Powershell卸载windows默认程序 Cisco路由器OSPF配置 汽车的分类和特点 Windows11跳过TPM2.0 SSL 证书工具 字数统计 BMI 计算 颜色转换 Hash 生成器 JSON 格式化 JWT 解码 房贷计算 时间戳转换 URL 编码 UUID 生成 牛马时钟 二维码批量生成器 CKS Simulator Kubernetes 1.25 FRP TOTP验证码生成器 direct-ssh-passthrough-nat 脚本使用说明 Python软件授权 Linux命令行百度网盘 为 Containerd 配置 Harbor 无证书镜像站 Docker一键部署Meta和MetacubexD面板 必应搜索屏蔽垃圾网站 VMware的ubuntu完整安装vm-tools支持粘贴板 Docker Desktop 安装到其他位置
HTTPS 双向认证与 USB 加密锁配置实战
Paper-Dragon · 2026-03-15 · via 运维开发绿皮书

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_clientssl_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)"

运行测试:

预期输出:

[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 模式。

  1. client.p12 传输到 Windows 机器(Linux 下可用 sz client.p12
  2. 打开加密锁管理软件,输入 PIN 码登录
  3. 点击"导入",选择 client.p12,输入导出密码(111111
  4. 导入成功后,证书和私钥存储在加密锁的安全芯片中,私钥不可导出

插入加密锁访问站点,浏览器弹出证书选择框,选择加密锁中的证书并输入 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_verifyNginx 变量,值为 SUCCESS / FAILED / NONE
$ssl_client_s_dn客户端证书的 DN 信息,可用于日志或后端鉴权

为什么用加密锁而不是直接导入浏览器

直接把 .p12 导入浏览器的问题在于:证书文件可以被复制和分发,一旦泄露就失去了访问控制的意义。

加密锁的私钥存储在硬件安全芯片中,私钥不可导出,所有加密运算在锁内完成。即使有人拿到了锁,还需要知道 PIN 码才能使用,实现了"持有硬件 + 知道密码"的双因素认证。

ET199 这类 PKI 加密锁价格不高,加上运费大概 30 元左右,适合对安全性有一定要求的内网管理系统。

更新日志

  • 9057c-docs: 新增 HTTPS 双向认证与 USB 加密锁配置实战