วิธีตั้งค่า mTLS ด้วย Nginx และการออกใบรับรอง Client
เรียนรู้วิธีจำกัดการเข้าถึง Admin Panel ด้วย mTLS โดยใช้ OpenSSL สร้าง CA ส่วนตัวและตั้งค่า Nginx ให้ปฏิเสธการเชื่อมต่อที่ไม่มีใบรับรองที่ถูกต้องเพื่อความปลอดภัยสูงสุด
หน้าที่ของ mTLS
Mutual TLS หรือที่มักเรียกกันว่า mTLS คือการที่ Nginx ร้องขอใบรับรอง (certificate) จากไคลเอนต์ทุกราย และจะปฏิเสธคำขอหากไม่มีใบรับรองดังกล่าว หรือใบรับรองนั้นไม่ได้ออกโดย Certificate Authority (CA) ที่คุณควบคุม การตรวจสอบนี้จะเกิดขึ้นภายในขั้นตอน TLS (transport layer security) handshake ดังนั้นผู้เรียกที่ไม่มีใบรับรองไคลเอนต์ที่ถูกต้องจะไม่สามารถเข้าถึงแอปพลิเคชันของคุณได้เลย นี่คือจุดเด่นของมัน กล่าวคือ แผงควบคุมผู้ดูแลระบบ (admin panel) หรือ endpoint สำหรับเก็บข้อมูลเมทริกซ์ (metrics endpoint) สามารถวางอยู่บนอินเทอร์เน็ตสาธารณะได้โดยไม่ต้องมีหน้าล็อกอิน และไม่มีช่องทางให้บอทสุ่มเดาข้อมูลได้
การสร้างระบบนี้มีขนาดเล็ก ประกอบด้วย CA ส่วนตัวหนึ่งชุดที่สร้างด้วย openssl, ใบรับรองหนึ่งใบต่อหนึ่งบุคคล และคำสั่ง (directive) สามรายการใน server block ของ Nginx งานที่จะตัดสินว่าระบบนี้จะใช้งานได้ยาวนานถึงหนึ่งปีหรือไม่นั้นขึ้นอยู่กับการปฏิบัติงาน ดังนั้นเนื้อหาส่วนใหญ่ในคู่มือนี้จึงครอบคลุมถึงอายุการใช้งาน, การเพิกถอนใบรับรอง, การออกใบรับรองรายบุคคล และวิธีรับมือเมื่อไคลเอนต์ถูกปฏิเสธการเชื่อมต่อโดยที่ไม่มีใครทราบสาเหตุ
สองเชนที่ไม่เกี่ยวข้องกัน
ในการตั้งค่า mTLS จะมี certificate chain อยู่สองชุดซึ่งไม่มีความเกี่ยวข้องกันเลย การพยายามรวมทั้งสองเข้าด้วยกันเป็นข้อผิดพลาดแรกที่เกือบทุกคนมักทำ
เชนแรกคือของฝั่งเซิร์ฟเวอร์ VPS ของคุณจะแสดง certificate สำหรับ admin.example.com ที่ออกโดย Public CA เช่น Let's Encrypt และเบราว์เซอร์จะตรวจสอบ certificate นั้นกับ root store ที่มาพร้อมกับระบบปฏิบัติการ ซึ่ง mTLS ไม่ได้เปลี่ยนแปลงกระบวนการในส่วนนี้ หากวันนี้ certbot ออก certificate ให้คุณแล้ว ให้คงการตั้งค่าไว้อย่างนั้น: ดูรายละเอียดที่ การออก certificate ของ Let's Encrypt สำหรับ nginx ด้วย certbot
เชนที่สองคือของฝั่งไคลเอนต์ คุณต้องสร้าง CA ขนาดเล็กขึ้นมาเอง จากนั้นลงนาม certificate ให้กับผู้ใช้งานแต่ละคนที่ต้องการเข้าถึงระบบ และกำหนดให้ nginx เชื่อถือเฉพาะ CA นั้นเพียงแห่งเดียวในการตรวจสอบไคลเอนต์ โดยไม่มี Public root store ใดรู้จัก CA ของคุณและไม่จำเป็นต้องรู้จักด้วย ฝ่ายเดียวที่ต้องเชื่อถือ CA นี้คือ nginx ผ่านทางไฟล์ ssl_client_certificate
ดังนั้น ssl_client_certificate จึงไม่มีผลต่อ certificate ที่ nginx แสดง และเชนของ Let's Encrypt ก็ไม่มีผลต่อการอนุญาตให้ไคลเอนต์เข้าถึงระบบ การชี้ ssl_client_certificate ไปที่ fullchain.pem ไม่ได้ให้ผลลัพธ์อย่างที่เข้าใจ เพราะ directive ดังกล่าวใช้ระบุผู้ออก certificate ที่ไคลเอนต์อาจนำมาแสดง ซึ่งเป็นคนละฝั่งของการเชื่อมต่อ การทำให้ตัวเซิร์ฟเวอร์เองเชื่อถือ CA ของคุณสำหรับการทำงานขาออก (outbound) เป็นอีกงานหนึ่งซึ่งอธิบายไว้ใน การเพิ่ม CA ของคุณเองลงใน Ubuntu trust store และระบบ trust store ของเครื่องก็ไม่ใช่สิ่งที่ nginx อ่านเมื่อทำการตรวจสอบไคลเอนต์
สร้าง CA สำหรับไคลเอนต์ด้วยตนเองโดยใช้ openssl
ให้สร้าง CA ไว้ในตำแหน่งอื่นที่ไม่ใช่บนเว็บเซิร์ฟเวอร์ nginx ต้องการเพียงใบรับรองสาธารณะ (public certificate) ของ CA เท่านั้น เนื่องจาก private key ของ CA ทำหน้าที่ลงนามในใบรับรองไคลเอนต์ใหม่ การทิ้งกุญแจนี้ไว้บนเครื่องที่เชื่อมต่อกับอินเทอร์เน็ตจะทำให้ผู้บุกรุกสามารถสร้างใบรับรองไคลเอนต์ที่ถูกต้องได้ตามต้องการหากเซิร์ฟเวอร์ถูกเจาะ
mkdir -p ~/client-ca/certs ~/client-ca/newcerts ~/client-ca/private ~/client-ca/csr
cd ~/client-ca
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumberindex.txt, serial และ crlnumber คือฐานข้อมูลของ CA โดย openssl ca จะไม่ทำงานหากไม่มีไฟล์เหล่านี้ นอกจากนี้ไฟล์ดังกล่าวยังเป็นสิ่งที่ทำให้การเพิกถอนใบรับรอง (revocation) เป็นไปได้ในภายหลัง เนื่องจากรายการเพิกถอนจะระบุหมายเลขซีเรียล ดังนั้น CA จึงจำเป็นต้องบันทึกไว้ว่าหมายเลขซีเรียลใดถูกออกให้แก่ใคร
เขียนไฟล์ ~/client-ca/openssl.cnf กำหนดค่า dir ให้เป็นพาธจริงของไดเรกทอรีนั้น เนื่องจาก openssl ca ไม่สามารถขยายค่า ~ ได้
[ ca ]
default_ca = client_ca
[ client_ca ]
dir = /home/you/client-ca
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/ca.crt
private_key = $dir/private/ca.key
serial = $dir/serial
crlnumber = $dir/crlnumber
default_md = sha256
default_days = 365
default_crl_days = 30
policy = policy_loose
rand_serial = no
unique_subject = no
email_in_dn = no
[ policy_loose ]
commonName = supplied
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
emailAddress = optional
[ client_ext ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuerจากนั้นสร้างกุญแจของ CA และใบรับรองที่ลงนามด้วยตนเอง (self-signed certificate):
openssl genrsa -aes256 -out private/ca.key 4096
chmod 600 private/ca.key
openssl req -x509 -new -key private/ca.key -sha256 -days 3650 \
-subj "/O=Example Ops/CN=Example Ops Client CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out ca.crt-aes256 จะกำหนดรหัสผ่านให้กับกุญแจของ CA ดังนั้นทุกครั้งที่มีการลงนาม ระบบจะถามรหัสผ่านนี้ ซึ่งเป็นจุดประสงค์หลักของมัน ตรวจสอบสิ่งที่คุณสร้างขึ้น:
openssl x509 -in ca.crt -noout -subject -dates -ext basicConstraintsหัวข้อ (subject) ควรเป็น CA ของคุณและอายุการใช้งานควรครอบคลุมสิบปี บรรทัดส่วนขยาย (extension line) ควรระบุเป็น CA:TRUE, pathlen:0 ค่า pathlen:0 หมายความว่า CA นี้สามารถลงนามในใบรับรองปลายทางได้ แต่ไม่สามารถลงนามเพื่อสร้าง CA อื่นได้ ซึ่งจะช่วยรักษาความลึกของสายใบรับรอง (chain) ไว้ที่ระดับเดียวและช่วยให้คุณไม่ต้องยุ่งกับ ssl_verify_depth
ออกใบรับรองไคลเอ็นต์แยกตามรายบุคคล
ให้ใช้ใบรับรองหนึ่งใบต่อหนึ่งบุคคล ห้ามใช้ใบรับรองร่วมกันสำหรับทั้งทีม เนื่องจากใบรับรองที่ใช้ร่วมกันจะไม่สามารถเพิกถอนได้โดยไม่ส่งผลกระทบต่อทุกคน และไม่สามารถระบุตัวตนของผู้เรียกใช้งานได้
openssl genrsa -out private/alice.key 2048
openssl req -new -key private/alice.key -out csr/alice.csr \
-subj "/O=Example Ops/CN=alice"
openssl ca -config openssl.cnf -extensions client_ext \
-days 365 -notext -in csr/alice.csr -out certs/alice.crtopenssl ca จะแสดงใบรับรองที่กำลังจะลงนาม สอบถามรหัสผ่านของ CA ให้ยืนยันสองครั้ง จากนั้นจะเพิ่มบรรทัดลงใน index.txt ให้เพิ่ม -batch เมื่อคุณเขียนสคริปต์ ส่วน client_ext มีความสำคัญเนื่องจากมีบรรทัดหนึ่งที่ระบุว่า extendedKeyUsage = clientAuth ใบรับรองที่มีการระบุ extended key usage เพียงแค่ serverAuth จะถูกปฏิเสธเนื่องจากไม่เหมาะสมสำหรับการยืนยันตัวตนไคลเอ็นต์ ดังนั้นควรระบุวัตถุประสงค์ให้ชัดเจนแทนการคาดเดา
ตรวจสอบคู่ใบรับรองกับ CA ก่อนส่งมอบให้ผู้อื่น:
openssl verify -CAfile ca.crt certs/alice.crtคำสั่งนี้จะแสดงผลเป็น certs/alice.crt: OK หากแสดงผลเป็นอย่างอื่น หมายความว่าใบรับรองและ CA ไม่ตรงกัน และการตั้งค่าใดๆ ใน nginx ก็ไม่สามารถแก้ไขปัญหานี้ได้
รวมไฟล์คีย์และใบรับรองเข้าเป็นไฟล์เดียวเพื่อให้เบราว์เซอร์นำเข้าได้:
openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
-name "alice at example ops" -out alice.p12การส่งออกไฟล์จะถามรหัสผ่านเพื่อป้องกันไฟล์ระหว่างการรับส่ง ให้ส่งไฟล์และรหัสผ่านผ่านช่องทางที่ต่างกัน และส่งมอบ .p12 ให้ผู้ใช้แทนการส่ง .key เปล่าๆ คุณสามารถเพิ่ม -certfile ca.crt เพื่อรวม CA ไว้ในชุดไฟล์ได้ แต่ nginx ไม่จำเป็นต้องใช้ เนื่องจาก nginx มี ca.crt อยู่แล้ว ดังนั้นใบรับรองที่ลงนามโดย CA นั้นโดยตรงจึงสามารถตรวจสอบความถูกต้องได้ด้วยตัวเอง
OpenSSL 3 ซึ่งมาพร้อมกับ Ubuntu 24.04 จะเขียนไฟล์ PKCS#12 ด้วยการเข้ารหัสแบบปัจจุบัน ซึ่งเบราว์เซอร์และระบบปฏิบัติการที่ใช้งาน ณ เดือนสิงหาคม 2026 สามารถอ่านได้ หากโปรแกรมนำเข้าไฟล์รุ่นเก่าปฏิเสธไฟล์ดังกล่าว ให้ส่งออกใหม่โดยเพิ่ม -legacy ซึ่งจะย้อนกลับไปใช้อัลกอริทึมรุ่นเก่าที่โปรแกรมเหล่านั้นรองรับ โปรดอ่านข้อความแจ้งเตือนจากโปรแกรมนำเข้าก่อนที่จะเลือกใช้แฟล็กดังกล่าว
การตั้งค่า nginx ด้วย ssl_client_certificate และ ssl_verify_client
คัดลอกไฟล์ CA certificate ไปยังเซิร์ฟเวอร์ โดยต้องเป็นไฟล์ CA certificate เท่านั้น
scp ca.crt user@admin.example.com:/tmp/client-ca.crt
ssh user@admin.example.com \
'sudo install -o root -g root -m 644 /tmp/client-ca.crt /etc/nginx/client-ca.crt'การตั้งค่าสิทธิ์เป็น 644 นั้นถูกต้องแล้ว เนื่องจาก CA certificate ถือเป็นข้อมูลสาธารณะ ส่วนไฟล์ CA key ต้องเก็บไว้ที่เครื่องคอมพิวเตอร์ของคุณเท่านั้น
จากนั้นให้เพิ่มคำสั่ง 3 รายการลงใน server block ที่ทำหน้าที่จัดการ TLS termination อยู่เดิม:
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
ssl_client_certificate /etc/nginx/client-ca.crt;
ssl_verify_client on;
ssl_verify_depth 1;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}ssl_verify_depth 1 คือค่าเริ่มต้นของ nginx ซึ่งกำหนดว่า client certificate จะต้องถูกลงนามโดย CA ที่ระบุไว้ในไฟล์นั้นโดยตรง ให้เพิ่มค่านี้ก็ต่อเมื่อคุณมีการใช้งาน intermediate CA เท่านั้น นอกจากนี้ nginx จะส่งรายชื่อ subject names จาก ssl_client_certificate ไปยังไคลเอนต์ระหว่างขั้นตอน handshake ซึ่งเป็นวิธีที่เบราว์เซอร์ใช้ตรวจสอบว่าควรเสนอ certificate ใบใดให้แก่เซิร์ฟเวอร์ พฤติกรรมดังกล่าวเป็นเหตุผลที่ควรใช้ ssl_client_certificate แทน ssl_trusted_certificate ซึ่งแม้จะตรวจสอบด้วยวิธีเดียวกันแต่จะไม่มีการส่งรายชื่อดังกล่าวไปให้
Ubuntu 24.04 มาพร้อมกับ nginx 1.24 ซึ่งใช้การตั้งค่า HTTP/2 ในบรรทัด listen ด้วยคำสั่ง listen 443 ssl http2; สำหรับ nginx เวอร์ชัน 1.25.1 ขึ้นไป รูปแบบดังกล่าวถูกเลิกใช้งานแล้ว และเปลี่ยนไปใช้คำสั่งแยกต่างหากคือ http2 on; ทั้งสองวิธีไม่มีผลต่อการตรวจสอบ certificate
โหลดการตั้งค่าใหม่และตรวจสอบผลลัพธ์:
sudo nginx -t && sudo systemctl reload nginx
curl -i https://admin.example.com/nginx -t จะแสดงผล syntax is ok และ test is successful ออกมา การเรียกใช้งาน curl โดยไม่มี certificate จะต้องได้รับผลลัพธ์เป็น 400 Bad Request พร้อมเนื้อหา No required SSL certificate was sent ซึ่งหมายความว่า nginx ปฏิเสธการเชื่อมต่อตั้งแต่ที่หน้าด่าน ทำให้มั่นใจได้ว่าการตั้งค่ามีผลใช้งานจริงและคำขอดังกล่าวไม่ถูกส่งต่อไปยังแอปพลิเคชัน จากนั้นให้ลองทดสอบอย่างถูกต้องอีกครั้ง:
curl --cert certs/alice.crt --key private/alice.key https://admin.example.com/ผลลัพธ์ที่ได้ควรเป็นข้อมูลที่แอปพลิเคชันของคุณให้บริการตามปกติ
เหตุผลที่ต้องวาง gate ไว้ใน server block
ใบรับรองจะถูกแลกเปลี่ยนในระหว่างขั้นตอน TLS handshake ก่อนที่ nginx จะอ่าน request line ดังนั้นในขณะนั้น nginx จึงยังไม่ทราบว่า request จะถูกส่งไปยัง location ใด การวาง ssl_verify_client on; ไว้ภายใน location เป็นการสั่งให้ไคลเอนต์ต้องทำการ renegotiate ใหม่ในระหว่างการเชื่อมต่อ ซึ่ง TLS 1.3 ได้ยกเลิกการ renegotiate ไปแล้วและ HTTP/2 ก็ไม่อนุญาตให้ทำเช่นนั้น ดังนั้นบน stack ปัจจุบันรูปแบบดังกล่าวจะล้มเหลวแทนที่จะเป็นการแจ้งเตือน
คุณต้องจัดการขอบเขต (scoping) ด้วยตนเอง โดยการเรียกขอใบรับรองในระดับ server แล้วจึงตัดสินใจในระดับ location:
ssl_verify_client optional;
location /metrics {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://127.0.0.1:9090;
}
location /healthz {
proxy_pass http://127.0.0.1:8080;
}$ssl_client_verify จะเก็บค่า SUCCESS หรือค่า NONE ในกรณีที่ไคลเอนต์ไม่ได้ส่งข้อมูลใดมา หรือค่า FAILED: ตามด้วยเหตุผลที่ระบุไว้ ด้วยการใช้ optional ทำให้ nginx ร้องขอใบรับรองและตรวจสอบเฉพาะเมื่อได้รับใบรับรองมาเท่านั้น ซึ่งเป็นสิ่งที่ทำให้ path /healthz ที่เป็นสาธารณะด้านบนทำงานได้ ในขณะที่ /metrics ยังคงถูกปิดกั้นอยู่ ใบรับรองที่ถูกส่งมาและไม่ผ่านการตรวจสอบจะถูก nginx ปฏิเสธในทันที หากคุณต้องการตรวจสอบใบรับรองที่ล้มเหลวด้วยตนเอง ให้ใช้ optional_no_ca จากนั้นการทดสอบของคุณจะต้องถือว่าทุกค่าที่ไม่ใช่ SUCCESS เป็นการปฏิเสธ
nginx มีรหัสสถานะที่ไม่เป็นมาตรฐานสำหรับกรณีนี้ และ error_page สามารถดักจับรหัสเหล่านั้นเพื่อให้ผู้เยี่ยมชมที่ถูกปฏิเสธได้รับคำอธิบายแทนที่จะเห็นเพียงข้อผิดพลาด 400:
error_page 495 496 = @needcert;
location @needcert {
default_type text/plain;
return 200 "This host requires a client certificate. Ask ops for one.\n";
}495 หมายถึงใบรับรองของไคลเอนต์ไม่ผ่านการตรวจสอบ 496 หมายถึงไคลเอนต์ไม่ได้แสดงใบรับรองใดๆ ให้ทำหน้าเพจดังกล่าวเป็นข้อความธรรมดา (plain text) เนื่องจากผู้ที่อ่านหน้านั้นจะไม่มี session และไม่มีบัญชีผู้ใช้
ฉันจะติดตั้ง client certificate ในเบราว์เซอร์ได้อย่างไร
Firefox มีที่เก็บ certificate แยกเป็นของตัวเอง ให้ไปที่ Settings จากนั้นเลือก Privacy and Security แล้วไปที่ View Certificates จากนั้นเลือกแท็บ Your Certificates แล้วกด Import จากนั้นเลือกไฟล์ .p12 และระบุรหัสผ่านของไฟล์
Chrome และ Edge ใช้ที่เก็บ certificate ของระบบปฏิบัติการบน Windows และ macOS ดังนั้นการเปิดไฟล์ .p12 จะเป็นการเริ่มตัวช่วยนำเข้าของระบบ สำหรับบน Linux นั้น Chrome จะอ่านฐานข้อมูล NSS (network security services) แยกต่างหากในโฮมไดเรกทอรีของคุณ ซึ่งการใช้เครื่องมือบรรทัดคำสั่งเป็นวิธีที่เชื่อถือได้มากกว่า:
sudo apt install -y libnss3-tools
pk12util -d sql:$HOME/.pki/nssdb -i alice.p12หลังจากนั้นให้โหลดเว็บไซต์ขึ้นมาใหม่ แล้วเบราว์เซอร์จะถามว่าต้องการส่ง certificate ใด Chrome จะจดจำตัวเลือกนั้นไว้ตลอดช่วงการใช้งานเบราว์เซอร์ ดังนั้นให้รีสตาร์ทเบราว์เซอร์หากต้องการให้ระบบถามอีกครั้ง certificate จะอยู่ในโปรไฟล์เบราว์เซอร์เดียวบนเครื่องเดียว ดังนั้น certificate ที่นำเข้าใน Firefox จะไม่ปรากฏใน Chrome และทั้งสองอย่างจะไม่ปรากฏในโทรศัพท์ของคุณ
การทดสอบด้วย curl --cert
ใช้ curl ในการดีบั๊ก เพราะเครื่องมือนี้จะรายงานสิ่งที่ทำลงไป
curl -v --cert certs/alice.crt --key private/alice.key https://admin.example.com/คุณสามารถรวมไฟล์ใบรับรองและคีย์เข้าเป็นไฟล์ PEM ไฟล์เดียว แล้วส่งผ่านด้วย --cert alice.pem ได้ หากคีย์มีรหัสผ่าน curl จะแจ้งให้คุณกรอก นอกจากนี้ยังรองรับ --cert alice.pem:passphrase ซึ่งจะทำให้รหัสผ่านไปปรากฏอยู่ในประวัติคำสั่งของ shell ดังนั้นควรเลือกใช้วิธีตอบรับผ่าน prompt จะปลอดภัยกว่า
มี 2 การตรวจสอบที่ควรทำก่อนจะสรุปว่าปัญหาเกิดจาก nginx อย่างแรกคือ ใบรับรองและคีย์ต้องเป็นคู่กัน:
openssl x509 -noout -pubkey -in certs/alice.crt | openssl sha256
openssl pkey -pubout -in private/alice.key | openssl sha256หากค่า hash เหมือนกันทั้งสองค่า แสดงว่าไฟล์ทั้งสองเป็นคู่กัน หากค่า hash ต่างกัน แสดงว่าคุณสลับไฟล์ของผู้อื่นมา และจะไม่มีไคลเอนต์ตัวใดแจ้งสาเหตุนี้ให้คุณทราบ
อย่างที่สองคือ เซิร์ฟเวอร์ควรจะร้องขอ CA ของคุณ:
openssl s_client -connect admin.example.com:443 -servername admin.example.com </dev/nullให้มองหาบล็อก Acceptable client certificate CA names ในผลลัพธ์ และตรวจสอบว่ามี subject ของ CA คุณอยู่ภายในนั้นหรือไม่ หากบล็อกดังกล่าวหายไปทั้งหมด แสดงว่า nginx ไม่ได้ร้องขอใบรับรองใน server block ที่ตอบกลับมา ซึ่งหมายความว่าคำสั่งของคุณอาจไปอยู่ใน server block อื่น โดยส่วนใหญ่มักจะเป็น default server
การส่งค่า client CN ไปยังแอปพลิเคชัน
ใบรับรองระบุว่าใครเป็นผู้เรียกใช้งาน แต่แอปพลิเคชันที่อยู่หลังพร็อกซีไม่สามารถมองเห็นเลเยอร์ TLS ได้ ดังนั้น nginx จึงต้องส่งชื่อดังกล่าวต่อไปด้วย
map $ssl_client_s_dn $client_cn {
default "";
"~,?CN=(?<cn>[^,]+)" $cn;
}$ssl_client_s_dn เก็บค่า subject distinguished name ในรูปแบบ RFC 2253 ซึ่งมีลักษณะเหมือน CN=alice,O=Example Ops คำสั่ง map จะดึงเฉพาะฟิลด์ CN ออกมาไว้ใน $client_cn ให้คงค่า CN ไว้เป็นชื่อผู้ใช้แบบธรรมดา เนื่องจากเครื่องหมายจุลภาคภายใน CN จะถูก escape ในรูปแบบดังกล่าว และนิพจน์ทั่วไป (regular expression) ขนาดเล็กด้านบนไม่รองรับการจัดการกับ escape เหล่านั้น
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Cert-CN $client_cn;
proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
}proxy_set_header จะแทนที่ header ใดๆ ที่มีชื่อเดียวกันซึ่งผู้เรียกส่งมา เพื่อไม่ให้ใครสามารถปลอมแปลง X-Client-Cert-CN ผ่าน location นี้ได้ โดยมีเงื่อนไขสองประการที่ทำให้สิ่งนี้เป็นจริง: nginx จะสืบทอด proxy_set_header จากระดับภายนอกก็ต่อเมื่อระดับภายในไม่ได้กำหนดค่าใดๆ ไว้ ดังนั้น location ที่สองที่มีบรรทัด proxy_set_header เพียงบรรทัดเดียวจะทำให้ header ทุกตัวที่ตั้งค่าไว้ก่อนหน้านี้สูญหายไปโดยไม่มีการแจ้งเตือน รวมถึง header นี้ด้วย และแอปพลิเคชันจะต้องไม่สามารถเข้าถึงได้จากภายนอกยกเว้นผ่าน nginx ซึ่งหมายถึงการผูก (bind) แอปพลิเคชันไว้กับ 127.0.0.1 แทนที่จะเป็น 0.0.0.0 เนื่องจากแอปพลิเคชันที่เปิดพอร์ตสาธารณะจะอ่าน header ที่ถูกปลอมแปลงมาจากอินเทอร์เน็ตโดยตรง ในส่วนของฝั่งพร็อกซีนั้นได้อธิบายไว้ใน คำอธิบายการตั้งค่า nginx reverse proxy ทีละบรรทัด หากแอปพลิเคชันต้องการใบรับรองทั้งหมดแทนที่จะเป็นเพียงชื่อ $ssl_client_escaped_cert จะทำหน้าที่ส่งใบรับรองนั้นในรูปแบบ URL-encoded ซึ่งมีความปลอดภัยภายใน header
ฉันจะเพิกถอนใบรับรองของไคลเอนต์หนึ่งรายการได้อย่างไร
เมื่อมีคนลาออกหรือแล็ปท็อปสูญหาย คุณสามารถเพิกถอนใบรับรองนั้นได้โดยที่ผู้ใช้รายอื่นยังคงทำงานได้ตามปกติ ซึ่งนี่คือเหตุผลหลักของการออกใบรับรองแยกรายบุคคล
cd ~/client-ca
openssl ca -config openssl.cnf -revoke certs/alice.crt
openssl ca -config openssl.cnf -gencrl -out crl.pemคำสั่งแรกจะเปลี่ยนสถานะบรรทัดของหมายเลขซีเรียลนั้นใน index.txt จาก V เป็น R ส่วนคำสั่งที่สองจะเขียนไฟล์ Certificate Revocation List (CRL) ซึ่งเป็นไฟล์ที่ลงลายมือชื่อดิจิทัลเพื่อระบุหมายเลขซีเรียลที่ถูกเพิกถอน จากนั้นให้ส่งไฟล์นี้ไปยังเซิร์ฟเวอร์และกำหนดค่าใน nginx ด้วย ssl_crl /etc/nginx/client-ca.crl; ควบคู่ไปกับคำสั่งอื่นๆ
scp crl.pem user@admin.example.com:/tmp/client-ca.crl
ssh user@admin.example.com 'sudo install -m 644 /tmp/client-ca.crl /etc/nginx/client-ca.crl && sudo nginx -t && sudo systemctl reload nginx'นี่คือกับดักที่อาจทำให้การเข้าถึงของทุกคนถูกปิดกั้น ไฟล์ CRL จะมีวันที่ nextUpdate ซึ่งกำหนดโดย default_crl_days โดยในตัวอย่างการตั้งค่าด้านบนคือ 30 วัน เมื่อพ้นกำหนดเวลานี้ OpenSSL จะถือว่ารายการข้อมูลล้าสมัยและปฏิเสธการตรวจสอบใบรับรองของไคลเอนต์ทุกรายการด้วยข้อผิดพลาด CRL has expired ไม่ใช่แค่เฉพาะรายการที่ถูกเพิกถอนเท่านั้น nginx จะอ่านไฟล์นี้เมื่อโหลดการตั้งค่า ดังนั้นการวางไฟล์ CRL ใหม่ลงในดิสก์จะไม่มีผลจนกว่าจะมีการโหลดซ้ำ (reload) คุณควรสร้างและโหลดไฟล์ใหม่ตามกำหนดเวลาที่เหมาะสม เช่น รายสัปดาห์สำหรับระยะเวลา 30 วัน และตรวจสอบวันที่ก่อนทำการคัดลอกไฟล์:
openssl crl -in crl.pem -noout -lastupdate -nextupdateสำหรับผู้ใช้จำนวนไม่มาก มีทางเลือกที่ง่ายกว่านั้น เนื่องจากคุณเป็นเจ้าของ CA คุณจึงสามารถให้ nginx ปฏิเสธหมายเลขซีเรียลโดยตรงได้โดยไม่ต้องใช้กลไก CRL:
map $ssl_client_serial $revoked {
default 0;
"1002" 1;
}ให้ใช้คำสั่งดังกล่าวคู่กับ if ($revoked) { return 403; } ในบล็อก location ซึ่งวิธีนี้ไม่มีวันหมดอายุให้ต้องคอยกังวล นอกจากนี้ ข้อมูลนี้จะไม่ถูกส่งต่อไปยังที่อื่น ดังนั้นบริการอื่นที่เชื่อถือ CA ของคุณจะไม่ทราบถึงการเพิกถอนนี้ สำหรับกรณีที่มี nginx หนึ่งตัวอยู่หน้าแอปพลิเคชันเดียว นี่คือวิธีที่ตรงไปตรงมาและเรียบง่ายที่สุด แต่หากคุณมีจุดเข้าใช้งานมากกว่าหนึ่งจุด ควรเปลี่ยนไปใช้ระบบ CRL แทน
ใบรับรองไคลเอนต์ควรมีอายุการใช้งานนานเท่าใด
กำหนดอายุใบรับรองไคลเอนต์ไว้ที่ 1 ปี หรือน้อยกว่านั้นหากคุณสามารถรับภาระงานในการออกใบรับรองใหม่ได้ การหมดอายุเป็นความล้มเหลวที่เงียบเชียบ เพราะไม่มีระบบแจ้งเตือนผู้ถือใบรับรองล่วงหน้า ผู้ใช้จะเปิดแผงควบคุมในตอนเช้าแล้วพบว่า Nginx ปฏิเสธการเชื่อมต่อ โดยเบราว์เซอร์จะแสดงข้อความปฏิเสธในรูปแบบของตนเอง ซึ่งมักไม่ระบุคำว่าหมดอายุ (expired) ให้ชัดเจน ควรตั้งอายุ CA ไว้ที่ 10 ปี และบันทึกวันหมดอายุไว้ในที่ที่คุณจะเปิดอ่านจริง ๆ เพราะเมื่อใบรับรอง CA หมดอายุ ใบรับรองทั้งหมดที่อยู่ภายใต้นั้นจะหยุดการตรวจสอบความถูกต้องในวันเดียวกันทันที
คำสั่งสองรายการต่อไปนี้จะช่วยให้คุณรับมือกับสถานการณ์ดังกล่าวได้:
openssl x509 -in certs/alice.crt -noout -subject -serial -enddate
awk -F'\t' '{print $1, $2, $4}' ~/client-ca/index.txtคอลัมน์แรกของ index.txt คือสถานะ: V สำหรับใบรับรองที่ใช้งานได้, R สำหรับใบรับรองที่ถูกเพิกถอน และ E สำหรับใบรับรองที่หมดอายุ คอลัมน์ที่สองคือวันหมดอายุในรูปแบบ YYMMDDHHMMSSZ และคอลัมน์ที่สี่คือหมายเลขซีเรียล ไฟล์ดังกล่าวเป็นบันทึกเพียงชุดเดียวที่ระบุว่าใครถือใบรับรองใดอยู่ ดังนั้นควรสำรองข้อมูลไฟล์นี้ไว้พร้อมกับคีย์ของ CA และเก็บรักษาข้อมูลทั้งสองส่วนนี้ไว้เป็นความลับ
การต่ออายุคือการออกใบรับรองใหม่ ไม่ใช่การขยายเวลาใบรับรองเดิม ให้สร้างคีย์และ CSR (certificate signing request) ชุดใหม่ขึ้นมา ลงนาม แล้วส่งมอบให้ผู้ใช้ จากนั้นจึงเพิกถอนใบรับรองเก่าเมื่อผู้ใช้ยืนยันว่าใบรับรองใหม่ใช้งานได้แล้ว
สิ่งที่ mTLS ป้องกันได้และป้องกันไม่ได้
สิ่งที่ mTLS ช่วยกำจัดคือการเข้าถึงโดยไม่ผ่านการตรวจสอบสิทธิ์ เครื่องมือสแกนที่พบชื่อโฮสต์ของคุณจะถูกปฏิเสธในระหว่างขั้นตอน handshake ดังนั้นเครื่องมือดังกล่าวจะไม่ส่งคำขอ HTTP ไม่เห็นหน้าล็อกอิน และไม่มีโอกาสลองใช้รหัสผ่านที่ขโมยมาได้ การทำ Credential stuffing จึงไม่มีช่องทางให้ใช้งาน ช่องโหว่ในขั้นตอนการล็อกอินของแอปพลิเคชันจะเข้าถึงไม่ได้สำหรับผู้ที่ไม่มีใบรับรอง นอกจากนี้ยังช่วยกำจัดปัญหาความลับร่วม (shared secret) ที่ผู้คนมักคัดลอกไปวางในแชท เพราะ private key เป็นไฟล์ที่ยากต่อการคัดลอกโดยไม่ตั้งใจ
สิ่งที่ mTLS ไม่สามารถช่วยได้คือกรณีที่ไคลเอนต์ถูกบุกรุก มัลแวร์บนแล็ปท็อปสามารถเข้าถึงไฟล์ key และทราบ passphrase ทันทีที่เจ้าของพิมพ์ สำหรับเซิร์ฟเวอร์แล้ว ผู้โจมตีจะดูเหมือนผู้ใช้ที่ถูกต้องตามกฎหมายทุกประการ เพราะใบรับรองเป็นเพียงการพิสูจน์การครอบครองไฟล์ ไม่ใช่การยืนยันตัวตนของบุคคล ดังนั้น .p12 รหัสผ่านและการเข้ารหัสข้อมูลทั้งดิสก์ (full disk encryption) จึงยังคงมีความสำคัญ
นอกจากนี้ mTLS ไม่ใช่การกำหนดสิทธิ์การเข้าถึง (authorization) ใบรับรองที่ถูกต้องทุกใบสามารถเข้าถึงทุกอย่างที่ server block นั้นให้บริการได้ เว้นแต่คุณจะตรวจสอบ $client_cn และดำเนินการตามค่าที่ได้รับ โดยปกติแล้วผู้ถือใบรับรองสองรายจะมีสิทธิ์เข้าถึงเท่ากันทุกประการ
และ mTLS จะป้องกันเฉพาะเส้นทางที่ผ่าน Nginx เท่านั้น หากแอปพลิเคชันเปิดพอร์ตสาธารณะไว้ด้วย การใช้ mTLS ไว้ด้านหน้าก็เป็นเพียงการตกแต่งเท่านั้น คุณควรผูกแอปพลิเคชันไว้กับ 127.0.0.1 และปิดพอร์ตนั้นไว้ที่ไฟร์วอลล์ ประตูอีกบานที่นำไปสู่เครื่องเดียวกันคือ SSH ซึ่งควรได้รับการดูแลในระดับเดียวกัน โดยมีรายละเอียดอยู่ใน การเพิ่มความปลอดภัยให้การเข้าถึง SSH บน VPS ของคุณ
ข้อจำกัดสุดท้ายที่มักสร้างปัญหาในวันที่คุณเปิดใช้งาน คือบริการใดก็ตามที่ไม่สามารถแสดงใบรับรองได้จะหยุดทำงานทันที เช่น ระบบตรวจสอบ uptime, webhook จากผู้ให้บริการชำระเงิน, โปรแกรมอ่าน RSS หรือแอปมือถือที่ไม่มีที่เก็บใบรับรองให้คุณเข้าถึงได้ คุณควรตัดสินใจเกี่ยวกับบริการเหล่านี้ก่อนที่จะตั้งค่า ssl_verify_client on เพราะความล้มเหลวที่เกิดขึ้นจะเป็นแบบเบ็ดเสร็จและมักไม่มีการแจ้งเตือนใดๆ กลับไปยังฝั่งของผู้ให้บริการเหล่านั้น
เมื่อไคลเอนต์ถูกปฏิเสธ ให้อ่านสิ่งที่ไคลเอนต์รายงาน
ข้อความที่ไคลเอนต์แสดงเมื่อถูกปฏิเสธจะขึ้นอยู่กับเบราว์เซอร์ เวอร์ชันของ curl และไลบรารี TLS ที่ใช้งานอยู่ ดังนั้นให้อ่านสิ่งที่ไคลเอนต์ของคุณแสดงผลออกมาแทนการเทียบกับข้อความที่ระบุไว้ในแหล่งอื่น รายละเอียดที่เป็นประโยชน์จะอยู่ที่ฝั่งเซิร์ฟเวอร์
sudo tail -n 50 /var/log/nginx/error.logใบรับรองที่ถูกปฏิเสธจะทิ้งบรรทัดที่มีข้อความ client SSL certificate verify error ตามด้วยเหตุผลที่ OpenSSL แจ้งมา เหตุผลนั้นคือสิ่งที่คุณต้องนำไปดำเนินการแก้ไข โดยปกติแล้วมักจะเป็นสาเหตุใดสาเหตุหนึ่งดังต่อไปนี้: ใบรับรองมาจาก CA ที่ต่างจากไฟล์ที่ระบุไว้ใน ssl_client_certificate, ใบรับรองอยู่นอกช่วงเวลาที่มีผลบังคับใช้ หรือ CRL บนเซิร์ฟเวอร์ได้ผ่านช่วง nextUpdate ไปแล้ว ทำให้เซิร์ฟเวอร์ปฏิเสธไคลเอนต์ทุกรายแทนที่จะเป็นเพียงรายเดียว
ในกรณีที่เบราว์เซอร์ไม่เสนอใบรับรองให้เลย ปัญหาจะเกิดขึ้นก่อนขั้นตอนการตรวจสอบ nginx จะส่งรายชื่อผู้ออกใบรับรอง (issuer names) ที่ยอมรับได้ในระหว่างการทำ handshake แต่เบราว์เซอร์ไม่พบใบรับรองที่ตรงกันในที่เก็บข้อมูล จึงไม่มีใบรับรองที่จะเสนอให้คุณ ให้ทำการนำเข้า .p12 อีกครั้งลงในโปรไฟล์ที่คุณใช้งานอยู่จริง
อีกกรณีที่ควรกล่าวถึงคือ หากคุณทดสอบด้วยใบรับรองไคลเอนต์แบบ self-signed เพียงใบเดียวแทนที่จะเป็นใบรับรองที่ CA ของคุณลงนาม การตรวจสอบจะไม่ผ่าน เนื่องจาก nginx จะตรวจสอบลายเซ็นเทียบกับไฟล์ CA และใบรับรองแบบ self-signed นั้นไม่ได้อยู่ในไฟล์ดังกล่าว กลไกการสร้างใบรับรองนั้นเหมือนกับใน การสร้างใบรับรองแบบ self-signed บน Ubuntu โดย mTLS เพียงแค่ต้องการขั้นตอนเพิ่มเติมคือการให้ CA ของคุณเป็นผู้ลงนามใบรับรองนั้น
FAQ
ฉันยังจำเป็นต้องใช้ใบรับรอง Let's Encrypt หากใช้ mTLS หรือไม่?
จำเป็น ใบรับรองทั้งสองประเภทนี้ไม่เกี่ยวข้องกัน เซิร์ฟเวอร์ของคุณต้องแสดงใบรับรองของตนเองเพื่อให้เบราว์เซอร์เชื่อถือชื่อโฮสต์ ซึ่งใบรับรองนี้ยังคงต้องออกโดย CA ที่เบราว์เซอร์รู้จักอยู่แล้ว ส่วน Client CA ของคุณเป็นเชนส่วนตัวแยกต่างหากที่ใช้เพื่อตรวจสอบว่าใครกำลังเชื่อมต่อเข้ามาเท่านั้น การตั้งค่า ssl_client_certificate ไม่ส่งผลใดๆ ต่อใบรับรองที่ nginx แสดง และไม่ควรชี้ไปยังเชนของ Let's Encrypt ของคุณ
ทำไมเบราว์เซอร์ของฉันไม่เคยถามให้เลือกใบรับรอง?
nginx จะส่งรายการผู้ออกใบรับรองที่ยอมรับได้ระหว่างการทำ handshake ซึ่งสร้างจากไฟล์ใน ssl_client_certificate เบราว์เซอร์จะเสนอเฉพาะใบรับรองที่มีผู้ออกใบรับรองปรากฏอยู่ในรายการนั้นเท่านั้น ดังนั้นหากไม่มีการแจ้งเตือน แสดงว่าเบราว์เซอร์ไม่มีใบรับรองจาก CA ของคุณ หรือการนำเข้าใบรับรองไปอยู่ในโปรไฟล์เบราว์เซอร์อื่น หรือใบรับรองถูกลงนามโดย CA อื่นที่ไม่ใช่ตัวเดียวกับที่ติดตั้งบนเซิร์ฟเวอร์ ให้รันคำสั่ง openssl s_client -connect admin.example.com:443 และตรวจสอบชื่อ CA ของใบรับรองฝั่งไคลเอนต์ที่ยอมรับได้ในผลลัพธ์ เพื่อดูว่าเซิร์ฟเวอร์กำลังร้องขอ CA ใดอยู่จริง
ฉันสามารถกำหนดให้ต้องใช้ใบรับรองไคลเอนต์เฉพาะบาง URL ได้หรือไม่?
ไม่ได้หากใช้ ssl_verify_client on ภายใน location ใบรับรองจะถูกแลกเปลี่ยนระหว่างการทำ handshake ก่อนที่ nginx จะทราบ path ของคำขอ และการทำ renegotiation เพื่อแก้ปัญหานี้ได้ถูกตัดออกไปใน TLS 1.3 และถูกห้ามใช้ใน HTTP/2 ให้ตั้งค่า ssl_verify_client optional; ใน server block จากนั้นในแต่ละ location ที่ต้องการป้องกัน ให้ตรวจสอบ $ssl_client_verify และส่งค่า 403 กลับหากค่าไม่ใช่ SUCCESS
ฉันจะเพิกถอนสิทธิ์การเข้าถึงของบุคคลหนึ่งได้อย่างไร?
ให้เพิกถอนใบรับรองนั้นด้วย openssl ca -revoke สร้างรายการใหม่ด้วย openssl ca -gencrl คัดลอกไปยังเซิร์ฟเวอร์ และ reload nginx เพื่อให้ระบบอ่านไฟล์ใหม่ ผู้ใช้อื่นจะไม่ได้รับผลกระทบ ซึ่งวิธีนี้จะได้ผลก็ต่อเมื่อแต่ละคนถือใบรับรองของตนเอง ไม่ใช่ใช้ใบรับรองร่วมกัน โปรดตรวจสอบวันที่ nextUpdate ของ CRL เพราะหาก CRL หมดอายุ การตรวจสอบจะล้มเหลวสำหรับไคลเอนต์ทุกคน ไม่ใช่แค่เฉพาะผู้ที่ถูกเพิกถอนเท่านั้น
mTLS สามารถใช้แทนหน้าล็อกอินได้หรือไม่?
ในแง่ของการเข้าถึงถือว่าได้ เพราะหากไม่มีใบรับรอง คำขอจะไม่สามารถเข้าถึงแอปพลิเคชันได้เลย จึงไม่มีฟอร์มให้โจมตีและไม่มีรหัสผ่านให้เดา แต่ในแง่ของการระบุตัวตนภายในแอปพลิเคชันนั้นไม่ได้ ใบรับรองเป็นเพียงการพิสูจน์ว่าผู้เรียกใช้งานถือไฟล์กุญแจอยู่ ดังนั้นหากแล็ปท็อปถูกขโมยไป ผู้ใช้งานคนนั้นก็จะยังคงเป็นผู้ใช้งานที่ถูกต้อง ให้ส่งค่า CN ไปยัง upstream และคงระบบบัญชีหรือสิทธิ์การใช้งานเดิมของแอปพลิเคชันไว้ โดยให้มองว่าใบรับรองเป็นเพียงประตูด่านหน้าเท่านั้น