Xây dựng OTP Server trên Ubuntu 24.04 LTS với privacyIDEA
Tài liệu triển khai thực tế — bản độc lập, đọc không cần tài liệu khác
Server: privacyIDEA 3.13.x trên Ubuntu 24.04 LTS + FreeRADIUS 3.2.5
Client: Windows RDP/console · Linux SSH · Web application
Bao gồm cả hai kịch bản: có Active Directory và không có AD (user nội bộ)
Phiên bản tài liệu: 1.0 — 08/2026
- PHẦN 0. SO SÁNH PHƯƠNG ÁN VÀ CHỐT LỰA CHỌN
- PHẦN 1. KIẾN TRÚC LAB, CHUẨN BỊ, ÔN LẠI TOTP
- PHẦN 2. DỰNG PRIVACYIDEA SERVER TRÊN UBUNTU 24.04
- PHẦN 3. USER: RESOLVER + REALM
- PHẦN 4. POLICY VÀ ENROLL TOKEN TOTP
- PHẦN 5. FREERADIUS — CẦU NỐI TỚI MỌI CLIENT
- PHẦN 6. CLIENT WINDOWS — RDP VÀ CONSOLE LOGON
- PHẦN 7. CLIENT LINUX — SSH
- PHẦN 8. TÍCH HỢP VÀO ỨNG DỤNG WEB
- PHẦN 9. BACKUP, DR VÀ HA
- PHẦN 10. TROUBLESHOOTING VÀ PHỤ LỤC
- PHẦN 11. PROXMOX VE, VMWARE VÀ ỨNG DỤNG WEB BÊN THỨ BA
- PHỤ LỤC A. LỆNH THAM CHIẾU NHANH
- PHỤ LỤC B. REST API THAM CHIẾU
- PHỤ LỤC C. FILE VÀ ĐƯỜNG DẪN QUAN TRỌNG
- PHỤ LỤC D. GIÁ TRỊ DỄ NHẦM — BẢNG TRA NHANH
PHẦN 0. SO SÁNH PHƯƠNG ÁN VÀ CHỐT LỰA CHỌN
0.1. Các phương án OTP server chạy được trên Ubuntu
Tôi đã đọc source code, script cài đặt và tài liệu upstream của từng phương án. Dưới đây là đánh giá dựa trên những gì thực sự có trong code, không phải theo trang marketing.
| Tiêu chí | privacyIDEA 3.13 | multiOTP 5.10 (native) | multiOTP 5.10 (Docker) | Keycloak 26.x | FreeIPA 4.x | FreeRADIUS + pam_oath thuần |
|---|---|---|---|---|---|---|
| License | AGPL-3.0 | LGPL-3.0 | LGPL-3.0 | Apache 2.0 | GPLv3 | GPLv2 |
| Ngôn ngữ | Python (Flask) | PHP | PHP | Java | Python/C | C |
| Cách cài trên Ubuntu 24.04 | ⭐ APT repo chính thức của NetKnights, suite noble |
❌ Không có .deb/repo. Chỉ có install.sh viết cho appliance |
🟡 Docker image chính thức, multi-arch | 🟡 tarball/Docker, không có repo Ubuntu | ❌ Không hỗ trợ Ubuntu (RHEL-family) | 🟡 apt, nhưng phải tự ghép mọi thứ |
| Thời gian dựng xong | ~30 phút | 2–4 giờ (phải sửa script) | ~30 phút | 2–3 giờ | — | 4+ giờ |
| Backend dữ liệu | MariaDB/PostgreSQL (SQLAlchemy) | File phẳng hoặc MariaDB | File phẳng | PostgreSQL | LDAP/389-DS | File users.oath |
| WebUI quản trị | ⭐ Đầy đủ (Angular), quản lý token/user/policy/audit | 🟡 Cơ bản (bản đầy đủ là Pro trả phí) | 🟡 Cơ bản | ⭐ Đầy đủ | 🟡 Có | ❌ Không có |
| RADIUS | ✅ FreeRADIUS + rlm_perl chính thức, có challenge-response |
✅ FreeRADIUS + rlm_exec/rlm_perl |
✅ FreeRADIUS trong container | ❌ Cần plugin bên thứ ba | ✅ | ⭐ Là chính nó |
| Windows RDP/console logon | ✅ privacyIDEA Credential Provider 3.8 (MSI, GPO, offline mode) | ✅ multiOTP Credential Provider | ✅ (dùng CP của multiOTP) | ❌ Không làm được OS logon | 🟡 Qua AD trust | ❌ |
| Linux SSH | ⭐ RADIUS + PAM module native có offline OTP | ✅ RADIUS | ✅ RADIUS | ❌ | ⭐ Kerberos + OTP tích hợp sâu | ✅ |
| Web app | ⭐ REST API sạch, có tài liệu | 🟡 API thô, khác nhau giữa bản | 🟡 | ⭐ OIDC/SAML chuẩn | 🟡 | ❌ |
| AD/LDAP sync | ⭐ LDAP resolver mạnh, hỗ trợ nested group, objectGUID |
✅ -ldap-users-sync |
✅ | ✅ | ⭐ Là IdM | 🟡 |
| Policy engine | ⭐ 11 scope, priority, điều kiện theo user/realm/client/time | 🟡 Cấu hình phẳng | 🟡 | ⭐ | 🟡 | ❌ |
| Audit log | ⭐ Ký số bằng RSA, xuất CSV, rotate | 🟡 File text | 🟡 File text | ✅ | ✅ | ❌ |
| Backup chính thức | ⭐ pi-manage backup create/restore |
🟡 -backup-config (changelog thừa nhận từng thiếu dữ liệu) |
🟡 | ✅ | ✅ | 🟡 |
| HA / nhiều node | ⭐ Nhiều app node + DB replication + HAProxy | ❌ Bản open source không có (HA là bản Pro) | ❌ | ⭐ Cluster native | ✅ replica | 🟡 |
| Loại token hỗ trợ | ⭐ TOTP, HOTP, FIDO2/WebAuthn, Passkey, PUSH, SMS, Email, Yubikey, giấy, TAN | TOTP, HOTP, mOTP, Yubikey, SMS, Email | như native | OTP + WebAuthn | TOTP/HOTP | TOTP/HOTP |
| Chất lượng tài liệu | ⭐ readthedocs đầy đủ, có phiên bản | ❌ Một file README ~2.600 dòng trộn changelog 2010–2026, nhiều chỗ lỗi thời | ❌ như native | ⭐ | ✅ | ✅ |
| Bus factor | 🟡 NetKnights GmbH, nhiều contributor | ❌ Gần như một tác giả duy nhất | ❌ | ⭐ Red Hat | ⭐ Red Hat | ⭐ |
| Ubuntu 24.04 | ⭐ Hỗ trợ chính thức, đã test | 🟡 "initial Ubuntu support" từ 03/2026, chưa có báo cáo cộng đồng | 🟡 (container chạy Debian 13) | ✅ | ❌ | ✅ |
0.2. ⭐ Chốt lựa chọn: privacyIDEA cài bằng APT trên Ubuntu 24.04
Lý do — bốn điểm quyết định:
1. Đây là phương án duy nhất có gói cài đặt gốc cho Ubuntu 24.04.
NetKnights duy trì APT repo với suite noble riêng. Bạn apt install privacyidea-apache2 và script postinst tự làm hết: tạo user hệ thống, sinh SECRET_KEY và PI_PEPPER ngẫu nhiên, tạo database MariaDB với mật khẩu ngẫu nhiên, sinh encryption key, sinh cặp khoá ký audit log, tạo bảng, cấu hình Apache + WSGI, sinh chứng chỉ TLS. Việc duy nhất còn lại của bạn là tạo tài khoản admin.
2. multiOTP trên Linux có vấn đề nghiêm trọng về mặt vận hành.
Không phải chê phần mềm — engine OTP của multiOTP tốt và đã OATH-certified. Vấn đề là cách nó được đóng gói cho Linux. Script cài duy nhất (raspberry/boot-part/install.sh) được viết cho appliance Raspberry Pi, và nếu chạy nguyên bản trên một server có sẵn nó sẽ:
- Đổi IP máy thành static 192.168.1.44 (ghi
/etc/netplan/,netplan apply) → mất SSH ngay lập tức - Đặt lại mật khẩu
root,ubuntu,pithànhraspberry - Bật
PermitRootLogin yes - Sửa GRUB thêm
net.ifnames=0→ interface đổi tên sau reboot iptables -P INPUT DROPvà ghi vàorules.v4chmod 777 -R /etc/multiotp— thư mục chứa seed OTP thành world-writable- Tạo RADIUS client
0.0.0.0/0với secretmyfirstpass - Tự reboot và tự xoá chính nó
Có thể sửa các biến đầu script để né, nhưng khi công cụ cài đặt của một sản phẩm bảo mật cần bạn đọc và vá 65 KB bash trước khi dám chạy, đó là tín hiệu rõ ràng rằng Linux không phải nền tảng chính của nó.
3. Ba thứ privacyIDEA có mà multiOTP open source không có, và bạn sẽ cần cả ba.
| Vì sao quan trọng | |
|---|---|
| Audit log ký số RSA | Khi có sự cố bảo mật, bạn cần log không sửa được. Đây cũng là yêu cầu của hầu hết chuẩn tuân thủ |
| HA nhiều node | multiOTP để tính năng HA ở bản Pro trả phí. privacyIDEA cho phép nhiều app node dùng chung DB, miễn phí |
| Offline OTP cho cả Windows và Linux | Laptop và chi nhánh mất mạng vẫn đăng nhập được. multiOTP chỉ có cache ở Credential Provider (Windows) |
4. Đường nâng cấp trong tương lai. privacyIDEA hỗ trợ FIDO2/WebAuthn và Passkey ngay trong cùng hệ thống. TOTP chống được password reuse và brute-force, nhưng không chống được real-time phishing. Khi bạn muốn nâng tài khoản admin lên khoá bảo mật phishing-resistant, privacyIDEA cho phép làm việc đó mà không phải thay hạ tầng. Đây là lý do dài hạn quan trọng nhất.
0.3. Khi nào không nên chọn privacyIDEA
Tôi nên nói rõ cả mặt kia:
| Tình huống | Nên chọn gì thay thế |
|---|---|
| Bạn chỉ có 1–2 máy Windows cần bảo vệ RDP, không có nhu cầu gì khác | multiOTP Credential Provider chế độ standalone — nó chứa engine OTP tích hợp, không cần server riêng. Dựng cả privacyIDEA cho 2 máy là quá tay |
| Bạn chỉ cần bảo vệ web app sau nginx/Traefik, không cần OS logon | Authelia — cấu hình bằng một file YAML, nhẹ hơn nhiều |
| Bạn cần SSO thật sự (OIDC/SAML) cho hàng chục web app | Keycloak |
| Đội bạn không có ai biết Python/Linux và toàn bộ hạ tầng là Windows | multiOTP trên Windows (xem tài liệu trước) |
| Bạn muốn thay thế AD hoàn toàn bằng Linux | FreeIPA (nhưng phải dùng RHEL/Rocky, không phải Ubuntu) |
Nếu tình huống của bạn nằm trong bảng trên, hãy dừng ở đây và chọn phương án tương ứng. Phần còn lại của tài liệu giả định bạn cần một OTP server tập trung phục vụ cả ba môi trường: Windows, Linux và web.
0.4. Ràng buộc và rủi ro của phương án đã chọn — biết trước để không bất ngờ
| Rủi ro | Mức độ | Cách giảm thiểu |
|---|---|---|
Gói privacyidea-apache2 vô hiệu hoá mọi website khác trên máy (postinst chạy rm -f /etc/apache2/sites-enabled/*) |
🔴 Cao | Dùng máy chuyên dụng cho OTP server. Không cài chung với web app khác |
Bản < 3.13.1 có bug pi-manage backup create hỏng trên Python 3.12 (chính là Python của Ubuntu 24.04) |
🔴 Cao | Bắt buộc dùng ≥ 3.13.1. Kiểm tra ngay sau khi cài |
pi-manage backup create mặc định KHÔNG kèm enckey |
🔴 Cao | Luôn dùng cờ -e, hoặc backup enckey riêng. Xem Phần 9 |
backup restore giải nén thẳng ra /, không chọn được đích |
🟠 Trung bình | Chỉ restore lên máy sạch hoặc trong container/VM cách ly |
pi-manage backup không hỗ trợ PostgreSQL (chỉ SQLite và MySQL/MariaDB) |
🟠 Trung bình | Dùng MariaDB (cũng là mặc định của gói) |
Gói privacyidea-mysql có thể không được build cho noble |
🟡 Thấp | Kiểm tra bằng apt-cache policy privacyidea-mysql trước khi dựa vào nó. Dùng privacyidea-apache2 |
| Cấu hình nginx đi kèm gói vẫn bật TLSv1/TLSv1.1 | 🟡 Thấp | Nếu chọn nginx thì sửa ssl_protocols TLSv1.2 TLSv1.3; |
| AGPL-3.0 | 🟡 Thấp | Chỉ ảnh hưởng nếu bạn sửa code và cung cấp dịch vụ ra ngoài. Dùng nội bộ thì không vướng |
privacyIDEA 3.14 sắp tới có breaking change (wildcard *, bỏ /validate/samlcheck, bỏ token u2f) |
🟡 Thấp | Đọc READ_BEFORE_UPDATE.md trước mỗi lần nâng cấp. Đừng bật auto-upgrade |
0.5. Kết quả cuối cùng sẽ có gì
Sau khi hoàn thành tài liệu này:
PHẦN 1. KIẾN TRÚC LAB, CHUẨN BỊ, ÔN LẠI TOTP
1.1. Ôn nhanh: TOTP hoạt động thế nào (đọc 3 phút)
Nếu bạn đã nắm rồi thì nhảy sang 1.2.
HOTP (RFC 4226) — mật khẩu một lần dựa trên bộ đếm sự kiện:
HOTP(K, C) = Truncate( HMAC-SHA1(K, C) ) mod 10^d
K= seed / secret, chuỗi bí mật chia sẻ giữa server và điện thoại. 160 bit (20 byte), hiển thị dạng Base32.C= bộ đếm, tăng mỗi lần người dùng bấm nút sinh mã.Truncate= lấy 4 bit thấp của byte cuối làm offset, đọc 4 byte từ vị trí đó, mask0x7FFFFFFF, rồimod 10^6.
TOTP (RFC 6238) = HOTP với bộ đếm lấy từ thời gian:
T = floor( (UnixTime - T0) / X ) với T0 = 0, X = 30 giây
TOTP(K) = HOTP(K, T)
Ba hệ quả thực tế bạn phải nhớ:
| Hệ quả | Ý nghĩa vận hành |
|---|---|
| Mã đổi mỗi 30 giây | Cửa sổ tấn công cực hẹp — đây là điểm mạnh chính |
| Server và điện thoại phải cùng giờ | NTP sai = không ai đăng nhập được, kể cả admin. Đây là nguyên nhân số 1 của mọi sự cố TOTP |
| Server chấp nhận lệch vài bước (time window) | Rộng quá thì dễ brute-force, hẹp quá thì user than phiền. privacyIDEA mặc định timeWindow = 180 giây (±3 bước) |
Chuẩn URI để nạp seed vào app (chính là nội dung mã QR):
otpauth://totp/LABEL?secret=BASE32SECRET&issuer=ISSUER&algorithm=SHA1&digits=6&period=30
⚠️ Giữ nguyên SHA1 / 6 digits / 30s. Đổi sang SHA256 cho "an toàn hơn" là cái bẫy kinh điển: Google Authenticator im lặng bỏ qua tham số algorithm và vẫn tính theo SHA1 → mã luôn sai và bạn sẽ mất nhiều giờ để hiểu tại sao. Chỉ đổi khi bạn kiểm soát 100% app phía client (Aegis, FreeOTP, KeePassXC hỗ trợ đủ).
TOTP không chống được gì: real-time phishing (Evilginx lừa bạn nhập OTP rồi dùng ngay trong 30 giây), malware trên máy đã đăng nhập, và kẻ tấn công đã lấy được file seed trên server. Muốn chống phishing thật sự thì phải dùng FIDO2/WebAuthn — privacyIDEA hỗ trợ sẵn, xem Phần 10.6.
1.2. Sơ đồ lab
1.3. Bảng máy chủ và tài nguyên
| Hostname | IP | OS | Vai trò | vCPU / RAM / Disk |
|---|---|---|---|---|
| OTP01 | 192.168.10.10 | Ubuntu 24.04 LTS | privacyIDEA + MariaDB + FreeRADIUS | 2 / 4 GB / 40 GB |
| OTP02 | 192.168.10.11 | Ubuntu 24.04 LTS | Node thứ hai (Phần 9.5) | 2 / 4 GB / 40 GB |
| DC01 | 192.168.10.5 | Windows Server 2022 | AD DS, DNS, NTP — tuỳ chọn | 2 / 4 GB |
| WINCLI01 | 192.168.10.21 | Windows 11 Pro | Test console logon | 2 / 4 GB |
| WINSRV02 | 192.168.10.22 | Windows Server 2022 | Test RDP | 2 / 4 GB |
| LNX01 | 192.168.10.31 | Ubuntu 24.04 LTS | Test SSH | 1 / 2 GB |
| WEB01 | 192.168.10.41 | Ubuntu 24.04 LTS | Web app | 2 / 2 GB |
💡 4 GB RAM là đủ cho vài nghìn user. privacyIDEA nhẹ; phần ngốn RAM nhất là MariaDB.
1.4. Bảng port
| Nguồn | Đích | Port/Proto | Mục đích |
|---|---|---|---|
| Admin | OTP01 | 443/TCP | WebUI + REST API |
| WINCLI01, WINSRV02 | OTP01 | 443/TCP | Credential Provider gọi /validate/check |
| WEB01 | OTP01 | 443/TCP | Web app gọi REST API |
| LNX01, WEB01, thiết bị mạng | OTP01 | 1812/UDP | RADIUS Authentication |
| Các client trên | OTP01 | 1813/UDP | RADIUS Accounting (tuỳ chọn) |
| OTP01 | DC01 | 636/TCP (LDAPS) | LDAP resolver — nên dùng LDAPS, không dùng 389 trần |
| Toàn bộ máy | DC01 / NTP nội bộ | 123/UDP | Đồng bộ thời gian |
| OTP01 | OTP02 | 3306/TCP hoặc 22/TCP | Replication / backup |
| Admin | OTP01 | 22/TCP | SSH quản trị |
Mở firewall trên OTP01:
sudo ufw allow 22/tcp comment 'SSH quan tri'
sudo ufw allow 443/tcp comment 'privacyIDEA WebUI + API'
sudo ufw allow 1812/udp comment 'RADIUS auth'
sudo ufw allow 1813/udp comment 'RADIUS accounting'
sudo ufw --force enable
sudo ufw status numbered
⚠️ Đừng mở port 80. Gói privacyIDEA có sẵn vhost :80 redirect sang :443, nhưng nếu không cần thì đóng luôn cho gọn bề mặt tấn công. Nếu bạn dùng Let's Encrypt với HTTP-01 challenge thì mới cần mở 80.
Siết chặt hơn — chỉ cho phép RADIUS từ những IP đã biết:
sudo ufw delete allow 1812/udp
sudo ufw allow from 192.168.10.31 to any port 1812 proto udp comment 'LNX01'
sudo ufw allow from 192.168.10.41 to any port 1812 proto udp comment 'WEB01'
1.5. Chuẩn bị OTP01
1.5.1. Cài Ubuntu 24.04 và cập nhật
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y curl wget gnupg ca-certificates software-properties-common \
chrony net-tools dnsutils jq
sudo reboot
Đặt hostname và tên miền đầy đủ:
sudo hostnamectl set-hostname otp01.lab.local
Kiểm tra /etc/hosts có dòng ánh xạ đúng (nếu không, Apache và một số script sẽ chậm do DNS lookup ngược):
sudo nano /etc/hosts
127.0.0.1 localhost
192.168.10.10 otp01.lab.local otp01
1.5.2. Đặt IP tĩnh (netplan)
sudo nano /etc/netplan/50-cloud-init.yaml
network:
version: 2
ethernets:
ens18: # đổi thành tên interface thật của bạn: ip -br a
dhcp4: false
addresses:
- 192.168.10.10/24
routes:
- to: default
via: 192.168.10.1
nameservers:
addresses: [192.168.10.5, 1.1.1.1]
search: [lab.local]
sudo chmod 600 /etc/netplan/50-cloud-init.yaml # netplan cảnh báo nếu file world-readable
sudo netplan try # tự rollback sau 120s nếu bạn mất kết nối
sudo netplan apply
ip -br a
💡 netplan try là bạn tốt: nó áp dụng cấu hình và tự động khôi phục nếu bạn không xác nhận trong 120 giây. Luôn dùng nó thay vì netplan apply khi đang SSH từ xa.
1.5.3. ⭐ Đồng bộ thời gian — làm TRƯỚC, không làm SAU
Đây là bước quan trọng nhất trong toàn bộ tài liệu. Bỏ qua nó thì mọi thứ sau đây sẽ hoạt động sai một cách khó hiểu.
Ubuntu 24.04 mặc định dùng systemd-timesyncd (SNTP client đơn giản). Với OTP server, hãy thay bằng chrony — nó chính xác hơn, xử lý được jitter của VM và có công cụ chẩn đoán tốt hơn.
sudo apt install -y chrony
sudo systemctl disable --now systemd-timesyncd 2>/dev/null || true
sudo nano /etc/chrony/chrony.conf
Thêm/sửa:
# Nguồn thời gian nội bộ (DC01) trước, pool công cộng làm dự phòng
server 192.168.10.5 iburst prefer
pool vn.pool.ntp.org iburst maxsources 3
# Cho phép bước nhảy lớn trong 3 lần hiệu chỉnh đầu (khi máy vừa boot / vừa restore snapshot)
makestep 1.0 3
# Ghi lại độ trôi của đồng hồ phần cứng
driftfile /var/lib/chrony/chrony.drift
rtcsync
# Ghi log để điều tra sự cố sau này
logdir /var/log/chrony
log measurements statistics tracking
sudo timedatectl set-timezone Asia/Bangkok
sudo systemctl restart chrony
sudo systemctl enable chrony
Kiểm tra — cả ba lệnh phải cho kết quả tốt:
$ timedatectl status
# System clock synchronized: yes ← BẮT BUỘC là yes
# NTP service: active
$ chronyc sources -v
# Cột đầu phải có dấu '^*' (nguồn đang được đồng bộ) hoặc '^+' (nguồn dự phòng tốt)
$ chronyc tracking
# System time : 0.000012345 seconds fast of NTP time ← phải < 0.1 giây
# Leap status : Normal
⚠️ Bẫy máy ảo — đọc kỹ nếu OTP01 chạy trên hypervisor:
| Hypervisor | Việc cần làm |
|---|---|
| VMware | Tắt "Synchronize guest time with host" trong VMware Tools, hoặc đặt tools.syncTime = "FALSE" trong file .vmx |
| Hyper-V | Tắt Time Synchronization trong Integration Services của VM |
| Proxmox/KVM | Thường không xung đột, nhưng kiểm tra qemu-guest-agent không ép giờ |
Và quan trọng nhất: sau mọi lần restore snapshot, vMotion, hoặc khôi phục từ backup — chạy ngay:
sudo chronyc makestep && chronyc tracking
"Tự dưng cả công ty không đăng nhập được" gần như luôn có nguyên nhân là ai đó vừa revert một snapshot.
Đưa việc giám sát độ lệch giờ vào monitoring ngay từ đầu:
sudo tee /usr/local/bin/check-time-drift.sh > /dev/null <<'EOF'
#!/bin/bash
# Cảnh báo nếu đồng hồ lệch quá ngưỡng — TOTP sẽ hỏng trước khi bạn kịp nhận ra
THRESHOLD=5 # giây
OFFSET=$(chronyc tracking | awk '/System time/ {print $4}')
ABS=$(echo "$OFFSET" | awk '{print ($1<0)?-$1:$1}')
if awk -v a="$ABS" -v t="$THRESHOLD" 'BEGIN{exit !(a>t)}'; then
logger -p auth.crit -t otp-timecheck "CANH BAO: dong ho lech ${ABS}s - TOTP CO THE HONG"
echo "CRITICAL: lech ${ABS}s"; exit 2
fi
echo "OK: lech ${ABS}s"; exit 0
EOF
sudo chmod +x /usr/local/bin/check-time-drift.sh
sudo /usr/local/bin/check-time-drift.sh
# Chạy mỗi 10 phút
echo '*/10 * * * * root /usr/local/bin/check-time-drift.sh >/dev/null' | sudo tee /etc/cron.d/otp-timecheck
1.5.4. Chuẩn bị chứng chỉ TLS
Gói privacyIDEA sẽ tự sinh chứng chỉ self-signed khi cài. Điều đó đủ để bắt đầu, nhưng Credential Provider trên Windows sẽ từ chối kết nối trừ khi bạn tắt kiểm tra chứng chỉ (không nên) hoặc dùng chứng chỉ được tin cậy.
Ba lựa chọn:
| Lựa chọn | Khi nào dùng | Ghi chú |
|---|---|---|
| CA nội bộ (AD CS) ⭐ | Có AD trong môi trường | Tốt nhất — CA đã được tin cậy sẵn trên mọi máy domain-joined qua GPO |
| Let's Encrypt | OTP server có tên miền public | Cần mở port 80 hoặc dùng DNS-01 challenge |
| Self-signed | Chỉ trong lab | Phải import thủ công vào Trusted Root của từng client |
Sinh CSR để xin chứng chỉ từ CA nội bộ:
sudo mkdir -p /etc/ssl/privacyidea && cd /etc/ssl/privacyidea
sudo openssl req -new -newkey rsa:4096 -nodes \
-keyout otp01.key -out otp01.csr \
-subj "/C=VN/ST=Hanoi/L=Hanoi/O=LAB/CN=otp01.lab.local" \
-addext "subjectAltName=DNS:otp01.lab.local,DNS:otp.lab.local,IP:192.168.10.10"
sudo chmod 600 otp01.key
cat otp01.csr
📌 SAN phải chứa cả tên DNS lẫn IP. Credential Provider và một số client gọi bằng IP; nếu SAN thiếu IP thì kết nối sẽ fail với lỗi CN mismatch. Thêm sẵn otp.lab.local làm alias — sẽ rất hữu ích khi cần failover sang OTP02 chỉ bằng cách đổi bản ghi DNS (xem Phần 9.5).
Mang otp01.csr sang CA nội bộ ký, rồi lấy về file chứng chỉ. Việc lắp vào Apache làm ở mục 2.6.
1.5.5. Danh sách kiểm tra trước khi sang Phần 2
# Chạy toàn bộ và xác nhận từng dòng
hostnamectl # hostname = otp01.lab.local
ip -br a # IP tĩnh đúng
timedatectl status | grep -E 'synchronized|Time zone' # synchronized: yes
chronyc tracking | grep 'System time' # lệch < 0.1s
ping -c2 192.168.10.5 # tới được DC (nếu có AD)
sudo ufw status # firewall đã bật, đúng port
lsb_release -a # Ubuntu 24.04
python3 --version # Python 3.12.x
free -h && df -h / # đủ RAM và disk
- Hostname là FQDN,
/etc/hostscó ánh xạ đúng - IP tĩnh, DNS trỏ đúng
chronychạy,System clock synchronized: yes, lệch < 0,1 giây- Đã tắt time sync của hypervisor
- Cron giám sát độ lệch giờ đã cài
- Firewall mở đúng 4 port, đóng phần còn lại
- Máy này chuyên dụng cho OTP — không chạy web app hay dịch vụ nào khác
- Đã có snapshot VM sạch để rollback nếu cần
PHẦN 2. DỰNG PRIVACYIDEA SERVER TRÊN UBUNTU 24.04
2.1. Thêm APT repo của NetKnights
privacyIDEA không có trong repo Ubuntu chính thức. Nguồn duy nhất được hỗ trợ là repo của NetKnights (công ty đứng sau dự án).
# 1. Tải khoá ký của repo
cd /tmp
wget https://lancelot.netknights.it/NetKnights-Release.asc
# 2. ⭐ KIỂM TRA FINGERPRINT — đây là bước bảo mật, đừng bỏ qua
gpg --import --import-options show-only --with-fingerprint NetKnights-Release.asc
Kết quả phải khớp chính xác:
pub 4096R/AE250082 2017-05-16
Key fingerprint = 0940 4ABB EDB3 586D EDE4 AD22 00F7 0D62 AE25 0082
uid NetKnights GmbH <[email protected]>
🔴 Nếu fingerprint không khớp, DỪNG LẠI. Bạn đang tải khoá từ nguồn bị can thiệp. Đây là repo sẽ cài phần mềm giữ toàn bộ seed MFA của tổ chức — không có chỗ cho sự cẩu thả ở bước này.
# 3. Cài khoá
sudo mv NetKnights-Release.asc /etc/apt/trusted.gpg.d/
sudo chmod 644 /etc/apt/trusted.gpg.d/NetKnights-Release.asc
# 4. Thêm repo — Ubuntu 24.04 = suite "noble"
echo "deb http://lancelot.netknights.it/community/noble/stable noble main" \
| sudo tee /etc/apt/sources.list.d/privacyidea-community.list
# 5. Cập nhật danh sách gói
sudo apt update
Kiểm tra repo hoạt động và xem phiên bản sẽ được cài:
apt-cache policy privacyidea
⚠️ Bắt buộc: phiên bản phải ≥ 3.13.1.
Các bản trước 3.13.1 có bug khiến pi-manage backup create hỏng trên Python 3.12 — chính là Python mặc định của Ubuntu 24.04. Nghĩa là bạn sẽ dựng được hệ thống nhưng không backup được, và chỉ phát hiện ra khi đã quá muộn.
Nếu repo stable chưa có ≥ 3.13.1, dùng suite devel chỉ để test, hoặc chờ. Đừng chạy production với bản không backup được.
Xem những gói nào có sẵn:
apt-cache search privacyidea
apt-cache policy privacyidea-apache2 privacyidea-nginx privacyidea-radius privacyidea-mysql
| Gói | Nội dung | Dùng khi nào |
|---|---|---|
privacyidea |
Chỉ code, cài vào /opt/privacyidea (dh-virtualenv) |
Khi bạn tự lo webserver + DB |
privacyidea-apache2 ⭐ |
Meta-package: privacyidea + Apache2 + mod_wsgi + MariaDB + rng-tools |
Khuyến nghị. Xung đột với nginx |
privacyidea-nginx |
Meta-package: nginx + uwsgi + MariaDB | Nếu bạn đã chuẩn hoá nginx. Xung đột với apache2 |
privacyidea-radius |
Module FreeRADIUS rlm_perl |
Xem Phần 5 — nếu gói này không có cho noble thì cài thủ công |
privacyidea-mysql |
App + DB, không webserver | ⚠️ Có thể không được build cho noble — kiểm tra trước |
2.2. Cài đặt
sudo apt install -y privacyidea-apache2
🔴 CẢNH BÁO — script postinst chạy rm -f /etc/apache2/sites-enabled/*.
Lệnh này vô hiệu hoá toàn bộ website khác đang chạy trên máy. Đây là lý do tài liệu yêu cầu OTP01 phải là máy chuyên dụng. Nếu bạn lỡ cài lên máy đang chạy web app, các site đó sẽ ngừng hoạt động ngay lập tức (file trong sites-available vẫn còn, chỉ mất symlink — có thể a2ensite lại, nhưng lúc đó bạn đã có downtime).
Postinst tự động làm những việc sau — biết để không làm lại thừa và để hiểu hệ thống:
| # | Việc | Chi tiết |
|---|---|---|
| 1 | Tạo user hệ thống | useradd -r privacyidea -m |
| 2 | Sinh secrets | PI_PEPPER và SECRET_KEY (24 ký tự ngẫu nhiên) → append vào pi.cfg |
| 3 | Tạo database | CREATE DATABASE pi; + user pi@localhost với mật khẩu ngẫu nhiên 12 ký tự |
| 4 | Ghi DB URI | SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://pi:<pw>@localhost/pi?charset=utf8' |
| 5 | Cấu hình Apache | a2enmod wsgi headers ssl rewrite + a2ensite privacyidea privacyidea-venv |
| 6 | Sinh encryption key | pi-manage setup create_enckey → /etc/privacyidea/enckey |
| 7 | Sinh khoá ký audit | pi-manage setup create_audit_keys → private.pem + public.pem |
| 8 | Sinh khoá PGP | pi-manage setup create_pgp_keys |
| 9 | Sinh cert TLS | Self-signed RSA 4096, hạn 1095 ngày → /etc/ssl/certs/privacyideaserver.pem |
| 10 | Tạo bảng DB | pi-manage setup create_tables |
| 11 | Siết quyền | chmod 600 enckey và private.pem; pi.cfg → privacyidea:root 640 |
| 12 | Symlink CLI | /opt/privacyidea/bin/pi-manage → /usr/bin/pi-manage |
Việc duy nhất bạn phải làm tiếp:
sudo pi-manage admin add admin -e [email protected]
# Nhập mật khẩu khi được hỏi — dùng mật khẩu RẤT MẠNH, đây là tài khoản
# kiểm soát toàn bộ hệ thống MFA của tổ chức
Kiểm tra ngay:
sudo pi-manage admin list
pi-manage --version # hoặc: apt-cache policy privacyidea | grep Installed
sudo systemctl status apache2 mariadb
Mở trình duyệt tới https://otp01.lab.local/ → chấp nhận cảnh báo self-signed (tạm thời) → đăng nhập bằng admin.
2.3. Hiểu các file quan trọng — nắm rõ trước khi làm gì tiếp
/etc/privacyidea/
├── pi.cfg 🔴 Cấu hình chính (chứa SECRET_KEY, PI_PEPPER, DB URI)
├── enckey 🔴🔴 KHOÁ MÃ HOÁ SEED — mất là mất TOÀN BỘ token
├── private.pem 🔴 Khoá ký audit log
├── public.pem 🟠 Khoá verify audit log
├── privacyideaapp.wsgi Entry point WSGI
└── gpg/ Khoá PGP
/opt/privacyidea/ Virtualenv chứa code (dh-virtualenv)
├── bin/pi-manage
└── lib/privacyidea/
/var/log/privacyidea/
└── privacyidea.log Log ứng dụng
/etc/apache2/sites-available/
├── privacyidea.conf vhost :443
└── privacyidea-venv.conf
/var/lib/privacyidea/backup/ Nơi pi-manage backup ghi file
🔴 /etc/privacyidea/enckey là file quan trọng nhất trên toàn bộ hệ thống.
Nó mã hoá mọi seed OTP trong database. Mất nó = không thể giải mã seed của bất kỳ ai = toàn bộ tổ chức phải enroll lại từ đầu. Không có cách khôi phục.
Đồng thời: ai có file này cùng với dump database thì sinh được OTP của mọi người, vĩnh viễn.
Xem Phần 9 để backup đúng cách. Hãy đọc phần đó trước khi enroll người dùng thật.
Xem file cấu hình:
sudo cat /etc/privacyidea/pi.cfg
Nội dung điển hình sau khi cài:
import logging
SUPERUSER_REALM = ['super', '(?#local admins)']
PI_ENCFILE = '/etc/privacyidea/enckey'
PI_AUDIT_KEY_PRIVATE = '/etc/privacyidea/private.pem'
PI_AUDIT_KEY_PUBLIC = '/etc/privacyidea/public.pem'
PI_AUDIT_SQL_TRUNCATE = True
PI_ENGINE_REGISTRY_CLASS = "shared"
PI_AUDIT_POOL_SIZE = 20
PI_LOGFILE = '/var/log/privacyidea/privacyidea.log'
PI_LOGLEVEL = logging.INFO
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://pi:xxxxxxxxxxxx@localhost/pi?charset=utf8'
SECRET_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxx'
PI_PEPPER = 'xxxxxxxxxxxxxxxxxxxxxxxx'
📌 pi.cfg được parse như code Python. Sai indentation hoặc thiếu dấu nháy sẽ làm cả ứng dụng không khởi động được. Sau mỗi lần sửa, kiểm tra bằng:
sudo python3 -c "exec(open('/etc/privacyidea/pi.cfg').read()); print('Cu phap OK')"
2.4. Hardening cấu hình
sudo cp /etc/privacyidea/pi.cfg /etc/privacyidea/pi.cfg.bak-$(date +%F)
sudo nano /etc/privacyidea/pi.cfg
Thêm vào cuối file:
# ══ URL công khai — BẮT BUỘC ═══════════════════════════════════
# Thiếu dòng này thì POST /recover (khôi phục mật khẩu) sẽ TỪ CHỐI hoạt động
# và tag {url} trong email thông báo sẽ rỗng.
PI_BASE_URL = 'https://otp01.lab.local'
# ══ Bảo mật tầng ứng dụng ══════════════════════════════════════
PI_FORCE_HTTPS = True # ép mọi request qua HTTPS
PI_ENABLE_CSP = True # bật Content-Security-Policy nghiêm ngặt
# ══ Log ════════════════════════════════════════════════════════
# Đặt DEBUG trong giai đoạn dựng, HẠ VỀ INFO khi lên production
PI_LOGLEVEL = logging.INFO
# ══ Nếu dùng nhiều app node dùng chung DB (Galera) ═════════════
# PI_DB_SAFE_STORE = True
Kiểm tra và áp dụng:
sudo python3 -c "exec(open('/etc/privacyidea/pi.cfg').read()); print('Cu phap OK')"
sudo systemctl restart apache2
sudo systemctl status apache2
Kiểm tra quyền file — postinst đã đặt đúng, nhưng xác nhận lại:
sudo ls -l /etc/privacyidea/
Kết quả phải là:
-rw------- 1 privacyidea root ... enckey ← 600, chỉ owner đọc
-rw------- 1 privacyidea root ... private.pem ← 600
-rw-r--r-- 1 privacyidea root ... public.pem ← 644
-rw-r----- 1 privacyidea root ... pi.cfg ← 640
Nếu lệch, sửa lại:
sudo chown privacyidea:root /etc/privacyidea/{pi.cfg,enckey,private.pem,public.pem}
sudo chmod 600 /etc/privacyidea/enckey /etc/privacyidea/private.pem
sudo chmod 640 /etc/privacyidea/pi.cfg
sudo chmod 644 /etc/privacyidea/public.pem
💡 Vì sao pi.cfg là mode 640 owner privacyidea group root? Đây là thiết kế có chủ đích: nó khiến user thường không chạy được pi-manage. Mọi lệnh pi-manage phải chạy bằng sudo. Đừng "sửa" quyền này cho tiện.
2.5. Nắm cú pháp pi-manage — phần rất dễ sai
🔴 Từ phiên bản 3.10, pi-manage đã được viết lại hoàn toàn bằng framework click và cấu trúc lệnh THAY ĐỔI.
Rất nhiều hướng dẫn trên Internet (và cả một số trang trong tài liệu chính thức) vẫn dùng cú pháp cũ. Lệnh cũ vẫn chạy được nhưng đã deprecated và bị ẩn khỏi --help, và sẽ biến mất.
Không tồn tại binary nào tên pi-manage-admin. Nếu bạn thấy hướng dẫn nào nhắc tới nó, hướng dẫn đó sai.
Cấu trúc mới:
pi-manage <nhóm> <lệnh con> [tham số]
Các nhóm: admin, audit, setup, config, backup, api, token, run.
Bảng chuyển đổi cú pháp cũ → mới:
| Cũ (≤ 3.9) — đừng dùng | Mới (3.10+) ⭐ |
|---|---|
pi-manage create_enckey |
pi-manage setup create_enckey |
pi-manage create_audit_keys |
pi-manage setup create_audit_keys |
pi-manage createdb |
pi-manage setup create_tables |
pi-manage dropdb |
pi-manage setup drop_tables |
pi-manage realm |
pi-manage config realm |
pi-manage resolver |
pi-manage config resolver |
pi-manage policy |
pi-manage config policy |
pi-manage event |
pi-manage config event |
pi-manage rotate_audit |
pi-manage audit rotate |
pi-manage runserver |
pi-manage run |
pi-manage admin add -p PW |
pi-manage admin add --password PW |
pi-manage backup create --conf_dir |
pi-manage backup create --config_dir |
Các lệnh bạn sẽ dùng thường xuyên:
# ── Quản trị viên ──────────────────────────────────────────────
sudo pi-manage admin add <user> -e <email> # mật khẩu sẽ được hỏi
sudo pi-manage admin list
sudo pi-manage admin change <user> --password <PW>
sudo pi-manage admin delete <user>
# ── Resolver và realm (Phần 3) ─────────────────────────────────
sudo pi-manage config resolver list
sudo pi-manage config resolver create <ten> <loai> <file_config.py>
sudo pi-manage config resolver create_internal <ten>
sudo pi-manage config realm list # dấu '*' = realm mặc định
sudo pi-manage config realm create <realm> <resolver1> [<resolver2>...]
sudo pi-manage config realm set_default <realm>
# ── Policy (Phần 4) ────────────────────────────────────────────
sudo pi-manage config policy list
sudo pi-manage config policy create <ten> <scope> "<action>"
sudo pi-manage config policy enable|disable|delete <ten>
# ── Export / import cấu hình ───────────────────────────────────
sudo pi-manage config export -f yaml -o /root/pi-config.yaml --censor
sudo pi-manage config import -i /root/pi-config.yaml
# ── Backup (Phần 9) ────────────────────────────────────────────
sudo pi-manage backup create -e # -e = KÈM enckey, RẤT QUAN TRỌNG
sudo pi-manage backup restore <file.tgz>
# ── Audit ──────────────────────────────────────────────────────
sudo pi-manage audit rotate --age 180
sudo pi-manage audit dump
# ── Dọn challenge cũ — BẮT BUỘC chạy định kỳ từ 3.10 ───────────
sudo pi-manage config challenge cleanup --age 60
⚠️ Lưu ý về pi-manage config realm create: nếu realm đã tồn tại, lệnh này thay thế nó chứ không báo lỗi. Cẩn thận khi chạy lại script.
⚠️ Từ 3.10, challenge chưa trả lời không còn tự dọn. Bảng challenge sẽ phình dần.
Gói cài đặt đã bật sẵn dòng challenge cleanup trong /etc/cron.d/privacyidea — chỉ cần xác nhận:
sudo cat /etc/cron.d/privacyidea
Trong file này, dòng audit rotate và backup create thì đang bị comment. Bạn sẽ tự cấu hình chúng ở Phần 9 và mục 10.4, nên có thể để nguyên.
2.6. Lắp chứng chỉ TLS thật
Chứng chỉ self-signed do postinst sinh ra chỉ dùng để bắt đầu. Thay bằng chứng chỉ từ CA nội bộ (đã xin ở mục 1.5.4):
# Sao lưu cert cũ
sudo cp /etc/ssl/certs/privacyideaserver.pem /etc/ssl/certs/privacyideaserver.pem.bak
sudo cp /etc/ssl/private/privacyideaserver.key /etc/ssl/private/privacyideaserver.key.bak
# Đặt cert mới (fullchain: cert của server + intermediate CA)
sudo cp /etc/ssl/privacyidea/otp01-fullchain.pem /etc/ssl/certs/privacyideaserver.pem
sudo cp /etc/ssl/privacyidea/otp01.key /etc/ssl/private/privacyideaserver.key
sudo chown root:root /etc/ssl/certs/privacyideaserver.pem /etc/ssl/private/privacyideaserver.key
sudo chmod 644 /etc/ssl/certs/privacyideaserver.pem
sudo chmod 600 /etc/ssl/private/privacyideaserver.key
sudo apache2ctl configtest && sudo systemctl reload apache2
Bật HSTS sau khi chắc chắn cert thật đã hoạt động:
sudo nano /etc/apache2/sites-available/privacyidea.conf
Bỏ comment dòng:
Header always set Strict-Transport-Security "max-age=1209600"
⚠️ Chỉ bật HSTS khi chắc chắn. Trình duyệt sẽ nhớ và từ chối kết nối HTTP tới host này trong 14 ngày, kể cả khi bạn cần rollback.
🔴 Cảnh báo trong cấu hình Apache mặc định — đọc và đừng phá:
LogFormat "%h %l %u %t \"%m %U %H\" %b ..." privacyIDEA
Định dạng log này cố ý không chứa %q (query string). Nếu bạn thêm %q vào, Apache sẽ ghi cả PIN và mã OTP của người dùng vào access log dạng plaintext. Đừng làm.
Kiểm tra TLS từ máy khác:
openssl s_client -connect otp01.lab.local:443 -servername otp01.lab.local </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
2.7. Kiểm tra hệ thống hoạt động
# 1. Dịch vụ chạy
sudo systemctl is-active apache2 mariadb
# 2. Cổng lắng nghe
sudo ss -tlnp | grep -E ':443|:3306'
# 3. Database có bảng
sudo mysql pi -e "SHOW TABLES;" | head -20
# 4. Log không có lỗi
sudo tail -30 /var/log/privacyidea/privacyidea.log
sudo tail -30 /var/log/apache2/error.log
# 5. API trả lời — lấy JWT
curl -s -k -X POST https://otp01.lab.local/auth \
-d "username=admin" -d "password=<mat-khau-admin>" | jq -r '.result.value.token' | head -c 60
Nếu bước 5 trả về một chuỗi JWT dài, hệ thống đã hoạt động.
Lưu JWT vào biến để dùng cho các phần sau:
export PI_URL="https://otp01.lab.local"
export PI_TOKEN=$(curl -s -k -X POST $PI_URL/auth \
-d "username=admin" -d "password=<mat-khau-admin>" | jq -r '.result.value.token')
echo "${PI_TOKEN:0:40}..."
📌 Header xác thực của privacyIDEA là PI-Authorization: <JWT> — truyền JWT thô, không có tiền tố Bearer. Đây là điểm khác với hầu hết API khác và là lỗi rất hay gặp khi viết script.
JWT mặc định có hạn 1 giờ, đổi bằng policy jwt_validity (scope webui).
2.8. Tóm tắt Phần 2
Các bước chính:
- Thêm repo NetKnights suite
noble, kiểm tra GPG fingerprint0940 4ABB EDB3 586D EDE4 AD22 00F7 0D62 AE25 0082. - Xác nhận phiên bản ≥ 3.13.1 (bug backup trên Python 3.12).
apt install privacyidea-apache2trên máy chuyên dụng (nó xoá symlink của mọi site Apache khác).pi-manage admin add admin -e ...— việc duy nhất cần làm thủ công.- Thêm
PI_BASE_URL,PI_FORCE_HTTPS,PI_ENABLE_CSPvàopi.cfg. - Thay chứng chỉ self-signed bằng cert từ CA nội bộ, SAN có cả DNS lẫn IP.
- Bỏ comment cron
challenge cleanup. - Ghi nhớ:
pi-manage <nhóm> <lệnh con>— cú pháp mới từ 3.10.
Lỗi thường gặp ở giai đoạn này:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
apt update báo NO_PUBKEY |
Khoá GPG chưa được cài đúng chỗ | Đặt file .asc vào /etc/apt/trusted.gpg.d/, chmod 644 |
E: Unable to locate package privacyidea-apache2 |
Sai suite (dùng jammy thay vì noble), hoặc repo không tới được |
Kiểm tra file .list; curl -I http://lancelot.netknights.it/community/noble/stable/ |
| Website khác trên máy ngừng chạy sau khi cài | postinst xoá sites-enabled/* |
a2ensite <site> để bật lại — và chuyển privacyIDEA sang máy riêng |
| Apache không start | pi.cfg sai cú pháp Python |
python3 -c "exec(open('/etc/privacyidea/pi.cfg').read())"; xem /var/log/apache2/error.log |
pi-manage: command not found |
Symlink chưa tạo, hoặc không chạy bằng sudo | ls -l /usr/bin/pi-manage; luôn dùng sudo |
pi-manage báo permission denied |
pi.cfg mode 640 owner privacyidea — cố ý |
Chạy bằng sudo |
| Đăng nhập WebUI báo lỗi 500 | DB chưa tạo bảng, hoặc DB URI sai | pi-manage setup create_tables; kiểm tra SQLALCHEMY_DATABASE_URI |
curl tới /auth trả 401 |
Sai mật khẩu admin | pi-manage admin change admin --password <PW-moi> |
| API trả 401 dù JWT đúng | Dùng Authorization: Bearer <token> |
Đổi thành PI-Authorization: <token> (không có Bearer) |
PHẦN 3. USER: RESOLVER + REALM
3.1. Mô hình user của privacyIDEA — hiểu trước khi cấu hình
privacyIDEA không lưu người dùng của bạn. Nó chỉ lưu token và gắn token vào một danh tính đến từ nơi khác. Kiến trúc gồm hai lớp:
| Khái niệm | Vai trò |
|---|---|
| Resolver | Kết nối tới một nguồn user: LDAP/AD, bảng SQL, file passwd, HTTP API. Trả lời câu hỏi "user này có tồn tại không, thông tin của họ là gì". |
| Realm | Một nhóm resolver. Người dùng đăng nhập với user@realm hoặc chỉ user (nếu là realm mặc định). Một realm có thể gộp nhiều resolver — hữu ích khi bạn có cả user AD lẫn user nội bộ. |
💡 Điểm mạnh của mô hình này: bạn có thể có cả AD lẫn user nội bộ trong cùng một realm. Nhân viên đăng nhập bằng tài khoản AD, còn tài khoản dịch vụ hoặc nhà thầu bên ngoài nằm trong resolver SQL nội bộ — cả hai đều dùng chung một hệ thống OTP, chung policy, chung audit log.
Bạn đã chọn cả hai kịch bản, nên mục 3.2–3.3 làm resolver AD, mục 3.4 làm resolver nội bộ, và mục 3.5 gộp cả hai vào một realm.
3.2. Chuẩn bị phía Active Directory
3.2.1. Tạo tài khoản dịch vụ để bind LDAP
Trên DC01 (PowerShell as Administrator):
PS> $pw = ConvertTo-SecureString (-join ((33..126) | Get-Random -Count 32 | %{[char]$_})) -AsPlainText -Force
PS> New-ADUser -Name "svc-privacyidea" `
-SamAccountName "svc-privacyidea" `
-UserPrincipalName "[email protected]" `
-Path "OU=Service Accounts,DC=lab,DC=local" `
-AccountPassword $pw `
-PasswordNeverExpires $true `
-CannotChangePassword $true `
-Enabled $true `
-Description "Bind account cho privacyIDEA LDAP resolver - CHI CAN QUYEN DOC"
# Hiển thị mật khẩu để copy sang OTP01 (rồi xoá khỏi lịch sử lệnh)
PS> [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($pw))
📌 Tài khoản này chỉ cần quyền ĐỌC. Đừng cho vào Domain Admins. Quyền mặc định của Authenticated Users đã đủ để đọc thuộc tính user thông thường. Nếu AD của bạn siết chặt hơn, cấp quyền Read trên OU chứa user.
3.2.2. Tạo group giới hạn phạm vi
PS> New-ADGroup -Name "OTP-Users" -GroupScope Global -GroupCategory Security `
-Path "OU=Groups,DC=lab,DC=local" `
-Description "Nhan vien bat buoc dung MFA"
PS> Add-ADGroupMember -Group "OTP-Users" -Members hoangnv,ducnm
⭐ Đừng bật MFA cho toàn bộ domain ngay từ đầu. Rollout theo đợt qua group này là cách kiểm soát rủi ro tốt nhất. Xem lộ trình ở Phần 10.5.
3.2.3. Lấy CA certificate để dùng LDAPS
LDAP trần (port 389) gửi mật khẩu bind dạng plaintext qua mạng. Bắt buộc dùng LDAPS (636).
Trên DC01, export CA certificate:
PS> certutil -ca.cert C:\temp\lab-ca.cer
PS> certutil -encode C:\temp\lab-ca.cer C:\temp\lab-ca.pem
Copy lab-ca.pem sang OTP01:
sudo mkdir -p /etc/privacyidea/certs
sudo cp /tmp/lab-ca.pem /etc/privacyidea/ldap-ca.crt
sudo chown privacyidea:root /etc/privacyidea/ldap-ca.crt
sudo chmod 644 /etc/privacyidea/ldap-ca.crt
Kiểm tra LDAPS hoạt động từ OTP01:
sudo apt install -y ldap-utils
# Test kết nối TLS
openssl s_client -connect dc01.lab.local:636 -showcerts </dev/null 2>/dev/null | head -20
# Test bind thật
LDAPTLS_CACERT=/etc/privacyidea/ldap-ca.crt ldapsearch -x -H ldaps://dc01.lab.local \
-D "[email protected]" -W \
-b "DC=lab,DC=local" \
"(&(objectClass=user)(sAMAccountName=hoangnv))" sAMAccountName mail displayName
🛑 Nếu ldapsearch không chạy được, DỪNG LẠI. Không có lý do gì để cấu hình resolver trong privacyIDEA khi LDAP còn chưa thông. Debug ở đây rẻ hơn nhiều so với debug qua WebUI.
3.3. Tạo LDAP resolver cho Active Directory
3.3.1. Cách 1 — WebUI (khuyến nghị cho lần đầu)
Config → Users → New LDAP resolver
Điền các trường sau — đây là bảng tham chiếu đầy đủ, tên trường chính xác theo source code:
| Trường | Giá trị cho AD | Giải thích |
|---|---|---|
| Resolver name | ad_lab |
Tên tự đặt |
| Server URI | ldaps://dc01.lab.local, ldaps://dc02.lab.local |
⭐ Nhận danh sách phân tách bằng dấu phẩy → tạo server pool round-robin, chính là failover DC |
| Base DN | DC=lab,DC=local |
Gốc tìm kiếm |
| Scope | SUBTREE |
Tìm cả cây con |
| Bind Type | Simple |
Với AD dùng Simple qua LDAPS. (NTLM cần DOMAIN\user; SASL Kerberos cần keytab) |
| Bind DN | [email protected] |
Hoặc DN đầy đủ CN=svc-privacyidea,OU=Service Accounts,DC=lab,DC=local |
| Bind Password | (mật khẩu đã tạo) | |
| Search Filter | (&(objectClass=user)(objectCategory=person)) |
Lọc ra user thật, loại computer account |
| Login Name Attribute | sAMAccountName, userPrincipalName |
⭐ Nhận list → user đăng nhập được bằng cả hai dạng |
| UID Type | objectGUID |
🔴 Quan trọng. Với AD phải là objectGUID — phân biệt hoa thường. Đây là ID bất biến; nếu để trống sẽ dùng DN và token sẽ mất liên kết khi user được di chuyển sang OU khác |
| Attribute mapping | (xem bên dưới) | JSON ánh xạ thuộc tính |
| No anonymous referral chase | ✅ Bật | ⭐ Gần như luôn cần với AD — thư viện ldap3 chỉ chase referral ẩn danh, AD sẽ từ chối |
| No schema retrieval | ✅ Bật | Tăng tốc đáng kể |
| TLS verify | ✅ Bật | |
| TLS CA File | /etc/privacyidea/ldap-ca.crt |
|
| Cache timeout | 120 |
Giây. Giảm tải lên DC |
| Timeout | 5 |
Giây |
| Size limit | 500 |
Số kết quả tối đa mỗi truy vấn |
| Editable | ❌ Tắt | Không cho privacyIDEA ghi vào AD |
Attribute mapping (dán vào ô Attribute mapping):
{"username": "sAMAccountName", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName", "accountExpires": "accountExpires"}
Bấm Test LDAP resolver → phải báo số user tìm được. Rồi bấm Save.
3.3.2. Cách 2 — CLI (dùng cho tự động hoá, IaC)
sudo tee /root/resolver-ad.py > /dev/null <<'EOF'
{
"LDAPURI": "ldaps://dc01.lab.local, ldaps://dc02.lab.local",
"LDAPBASE": "DC=lab,DC=local",
"AUTHTYPE": "Simple",
"BINDDN": "[email protected]",
"BINDPW": "MAT_KHAU_SERVICE_ACCOUNT",
"TIMEOUT": "5",
"SIZELIMIT": "500",
"CACHE_TIMEOUT": "120",
"LOGINNAMEATTRIBUTE": "sAMAccountName, userPrincipalName",
"LDAPSEARCHFILTER": "(&(objectClass=user)(objectCategory=person))",
"USERINFO": "{\"username\": \"sAMAccountName\", \"phone\": \"telephoneNumber\", \"mobile\": \"mobile\", \"email\": \"mail\", \"surname\": \"sn\", \"givenname\": \"givenName\", \"accountExpires\": \"accountExpires\"}",
"UIDTYPE": "objectGUID",
"NOREFERRALS": "1",
"NOSCHEMAS": "1",
"TLS_VERIFY": "1",
"TLS_CA_FILE": "/etc/privacyidea/ldap-ca.crt",
"SCOPE": "SUBTREE",
"EDITABLE": "0"
}
EOF
sudo chmod 600 /root/resolver-ad.py # chứa mật khẩu bind
sudo pi-manage config resolver create ad_lab ldapresolver /root/resolver-ad.py
sudo pi-manage config resolver list
# Xoá file chứa mật khẩu sau khi xong
sudo shred -u /root/resolver-ad.py
3.3.3. Chỉ lấy user trong group OTP-Users
Sửa Search Filter để lọc theo group:
(&(objectClass=user)(objectCategory=person)(memberOf=CN=OTP-Users,OU=Groups,DC=lab,DC=local))
Hoặc — nếu cần nested group (user thuộc group con của OTP-Users) — dùng extensible match của AD:
(&(objectClass=user)(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=CN=OTP-Users,OU=Groups,DC=lab,DC=local))
📌 Chuỗi 1.2.840.113556.1.4.1941 là OID LDAP_MATCHING_RULE_IN_CHAIN của Microsoft — nó bảo AD duyệt đệ quy quan hệ thành viên. Tốn tài nguyên DC hơn, nhưng đúng khi cấu trúc group của bạn có nhiều tầng.
Từ privacyIDEA 3.12 còn có ba trường riêng cho nested group: group_base_dn, group_search_filter, group_name_attribute.
3.4. Tạo resolver cho user nội bộ (không có AD)
Dùng khi: không có AD, hoặc cần thêm tài khoản nhà thầu / tài khoản dịch vụ không nằm trong AD.
3.4.1. Cách nhanh nhất — SQL resolver nội bộ
Một lệnh duy nhất tạo bảng user ngay trong database của privacyIDEA:
sudo pi-manage config resolver create_internal local_users
Lệnh này tạo bảng users_local_users với các cột id, username, email, password, phone, mobile, surname, givenname, description, đặt Editable=1 và ánh xạ thuộc tính sẵn.
Kiểm tra:
sudo pi-manage config resolver list
sudo mysql pi -e "DESCRIBE users_local_users;"
Thêm user qua WebUI: Users → (chọn realm) → Add User
Thêm user qua REST API:
export PI_TOKEN=$(curl -s -k -X POST $PI_URL/auth \
-d "username=admin" -d "password=<mat-khau>" | jq -r '.result.value.token')
curl -s -k -X POST $PI_URL/user/ \
-H "PI-Authorization: $PI_TOKEN" \
-d "user=nhathau01" \
-d "resolver=local_users" \
-d "[email protected]" \
-d "givenname=Tran" \
-d "surname=Van B" \
-d "password=MatKhauTamThoi#2026" | jq
💡 Vì sao user nội bộ cần mật khẩu? Vì policy otppin=userstore (mục 4.2) sẽ dùng mật khẩu này làm yếu tố thứ nhất. Nếu bạn dùng otppin=tokenpin thì mật khẩu trong resolver không được dùng tới.
3.4.2. Cách 2 — passwd resolver (đọc file dạng /etc/passwd)
Dùng khi bạn muốn quản lý user bằng file phẳng.
sudo tee /root/resolver-passwd.py > /dev/null <<'EOF'
{"fileName": "/etc/privacyidea/pi-users"}
EOF
sudo pi-manage config resolver create file_users passwdresolver /root/resolver-passwd.py
sudo rm /root/resolver-passwd.py
🔴 Key phải là fileName (chữ N hoa), không phải Filename. Source code chỉ chấp nhận fileName hoặc filename. Nếu bạn viết Filename, giá trị bị bỏ qua và resolver âm thầm rơi về mặc định /etc/passwd — đúng cái bẫy được cảnh báo ngay dưới đây.
⚠️ Đừng trỏ vào /etc/passwd thật. File đó không chứa mật khẩu (mật khẩu nằm trong /etc/shadow), nên policy otppin=userstore sẽ không hoạt động. Thay vào đó, sinh file riêng bằng công cụ đi kèm:
sudo /opt/privacyidea/bin/privacyidea-create-pwidresolver-user \
-u nhathau01 -i 1002 | sudo tee -a /etc/privacyidea/pi-users
sudo chown privacyidea:root /etc/privacyidea/pi-users
sudo chmod 600 /etc/privacyidea/pi-users
3.4.3. So sánh nhanh ba loại resolver nội bộ
SQL nội bộ (create_internal) |
passwd resolver | SQL resolver tự cấu hình | |
|---|---|---|---|
| Độ khó | ⭐ Một lệnh | Dễ | Trung bình |
| Sửa user qua WebUI | ✅ | ❌ (sửa file) | ✅ nếu Editable=1 |
| Nằm trong backup của privacyIDEA | ✅ (cùng DB) | ❌ (phải backup file riêng) | Tuỳ |
| Dùng chung với hệ thống khác | ❌ | 🟡 | ✅ (trỏ vào DB app có sẵn) |
| Khuyến nghị | ⭐ Dùng cái này | Trường hợp đặc biệt | Khi đã có DB user sẵn |
3.5. Tạo realm — gộp AD và user nội bộ
# Realm chính: AD trước, user nội bộ sau
sudo pi-manage config realm create lab ad_lab local_users
# Đặt làm realm mặc định -> user gõ "hoangnv" thay vì "hoangnv@lab"
sudo pi-manage config realm set_default lab
sudo pi-manage config realm list
Kết quả (dấu * đánh dấu realm mặc định):
*lab
ad_lab (priority: 1)
local_users (priority: 2)
Thứ tự resolver có ý nghĩa. privacyIDEA tìm user theo thứ tự ưu tiên; user đầu tiên khớp sẽ thắng. Đặt AD trước để tài khoản công ty luôn được ưu tiên hơn tài khoản nội bộ trùng tên.
Nếu chỉ có user nội bộ (không có AD):
sudo pi-manage config resolver create_internal local_users
sudo pi-manage config realm create lab local_users
sudo pi-manage config realm set_default lab
Tạo realm riêng cho nhà thầu (tuỳ chọn — tách policy và audit):
sudo pi-manage config resolver create_internal contractors
sudo pi-manage config realm create ext contractors
Khi đó nhà thầu đăng nhập bằng nhathau01@ext, và bạn có thể áp policy chặt hơn riêng cho realm ext.
3.6. Kiểm tra resolver và realm
Qua WebUI: Users → chọn realm → phải thấy danh sách user.
Qua CLI:
sudo pi-manage config resolver list
sudo pi-manage config realm list
Qua API — kiểm tra một user cụ thể:
curl -s -k -G $PI_URL/user/ \
-H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "realm=lab" \
--data-urlencode "username=hoangnv" | jq '.result.value'
Kết quả mong đợi:
[
{
"description": "",
"editable": false,
"email": "[email protected]",
"givenname": "Hoang",
"mobile": "0901234567",
"resolver": "ad_lab",
"surname": "Nguyen Van",
"userid": "a1b2c3d4-...",
"username": "hoangnv"
}
]
📌 Trường userid với AD sẽ là objectGUID — chuỗi bất biến. Nếu bạn thấy nó là một DN (CN=...,OU=...), nghĩa là UIDTYPE chưa được đặt đúng. Sửa ngay trước khi enroll token, vì đổi UIDTYPE sau đó sẽ làm mọi token mất liên kết với user.
3.7. Tóm tắt Phần 3
Các bước chính:
- AD: tạo
svc-privacyidea(chỉ quyền đọc), tạo groupOTP-Users, export CA cert cho LDAPS. - Test bằng
ldapsearchtrước khi động vào privacyIDEA. - Tạo LDAP resolver với
UIDTYPE=objectGUID,NOREFERRALS=1,NOSCHEMAS=1, LDAPS + TLS verify. - Không AD:
pi-manage config resolver create_internal local_users— một lệnh là xong. - Gộp cả hai vào một realm:
pi-manage config realm create lab ad_lab local_users. set_defaultđể user không phải gõ@realm.- Verify bằng API trước khi sang Phần 4.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
| Test resolver báo 0 user | Sai Base DN hoặc Search Filter | Test lại bằng ldapsearch với đúng filter đó |
LDAPBindError / invalidCredentials |
Sai Bind DN hoặc mật khẩu; AD khoá tài khoản | Thử ldapsearch -D ... -W; kiểm tra tài khoản có bị lock |
certificate verify failed |
CA cert sai hoặc CN không khớp | Kiểm tra TLS_CA_FILE; đảm bảo dùng đúng FQDN của DC, không dùng IP |
| Kết nối rất chậm hoặc timeout | Chưa bật NOREFERRALS — ldap3 đang cố chase referral |
Bật No anonymous referral chase |
userid là DN thay vì GUID |
UIDTYPE để trống |
Đặt objectGUID trước khi enroll token |
| Token mất liên kết với user sau khi chuyển OU | Dùng DN làm UID | Phải dùng objectGUID. Nếu đã lỡ: gán lại token thủ công |
| User đăng nhập bằng UPN không được | LOGINNAMEATTRIBUTE chỉ có sAMAccountName |
Đổi thành sAMAccountName, userPrincipalName |
| Sửa user nội bộ báo lỗi read-only | Resolver Editable=0 |
Dùng create_internal, hoặc bật Editable cho SQL resolver |
| DC quá tải vì privacyIDEA query liên tục | CACHE_TIMEOUT=0 |
Đặt CACHE_TIMEOUT=120 |
PHẦN 4. POLICY VÀ ENROLL TOKEN TOTP
4.1. Policy engine — hiểu cơ chế trước khi viết policy
Đây là thứ mạnh nhất của privacyIDEA và cũng dễ gây bối rối nhất.
11 scope (phạm vi) — mỗi cái điều khiển một giai đoạn khác nhau:
| Scope | Điều khiển |
|---|---|
authentication |
⭐ Quá trình xác thực: yếu tố thứ nhất là gì, challenge-response, cache |
authorization |
Sau khi xác thực xong: cho vào hay không, trả thêm thuộc tính gì |
admin |
Quản trị viên được làm gì |
user |
Người dùng tự phục vụ được làm gì |
enrollment |
⭐ Quy tắc khi đăng ký token: nhãn, số token tối đa, xác nhận enroll |
webui |
Giao diện web: ngôn ngữ, timeout, login mode |
audit |
Quyền xem audit log |
token, container, register, hardening |
Các phạm vi chuyên biệt |
Thuộc tính của một policy:
| Thuộc tính | Ý nghĩa |
|---|---|
name |
Tên (chỉ 0-9 a-z A-Z _ - space .) |
scope |
Một trong 11 scope trên |
action |
Hành động, dạng key=value hoặc chỉ key, nhiều cái ngăn bằng dấu phẩy |
priority |
Số dương, mặc định 1. ⭐ Số NHỎ hơn thắng khi có xung đột |
realm, resolver, user |
Giới hạn policy chỉ áp cho nhóm nhất định |
client |
Giới hạn theo IP/subnet nguồn — rất hữu ích |
time |
Giới hạn theo khung giờ |
active |
Bật/tắt |
📌 Các policy cùng scope là CỘNG DỒN (additive), không phải ghi đè. Nếu hai policy cùng cho phép hai action khác nhau, người dùng có cả hai. Chỉ khi hai policy đặt cùng một key với giá trị khác nhau thì priority mới quyết định.
⚠️ Bẫy lớn nhất của policy engine: khi chưa có policy nào trong một scope, privacyIDEA cho phép mọi thứ trong scope đó. Ngay khi bạn tạo policy đầu tiên trong scope admin hoặc user, mọi thứ không được liệt kê sẽ bị cấm. Nghĩa là tạo một policy admin sơ sài có thể khiến chính bạn mất quyền quản trị. Luôn kiểm tra bằng một session admin khác trước khi đăng xuất.
4.2. ⭐ Policy quan trọng nhất: otppin
Đây là policy quyết định yếu tố thứ nhất là gì. Nó thay đổi hoàn toàn trải nghiệm người dùng.
| Giá trị | Người dùng nhập gì vào ô "password" | Dùng khi nào |
|---|---|---|
tokenpin |
PIN của token + OTP, dính liền. VD: PIN 4829 + OTP 173940 → 4829173940 |
Mặc định. Khi bạn muốn PIN độc lập với mật khẩu hệ thống |
userstore ⭐ |
Mật khẩu AD/LDAP/SQL + OTP, dính liền. VD: MatKhau123456789 |
⭐ Khuyến nghị khi có AD. Người dùng chỉ phải nhớ mật khẩu công ty |
none |
Chỉ OTP, không cần gì thêm | Khi yếu tố thứ nhất đã được kiểm ở nơi khác (VD: Credential Provider đã kiểm mật khẩu Windows) |
# Với AD: dùng mật khẩu AD làm yếu tố 1
sudo pi-manage config policy create auth_userstore authentication "otppin=userstore"
⚠️ Điểm quan trọng của otppin=userstore với AD: mật khẩu được kiểm bằng một LDAP bind ngay tại thời điểm xác thực. Hệ quả:
- ✅ Người dùng đổi mật khẩu AD → có hiệu lực ngay, không cần đồng bộ
- ✅ Tài khoản bị khoá/vô hiệu hoá trong AD → OTP cũng ngừng hoạt động ngay
- ❌ DC không truy cập được = không ai đăng nhập được — kể cả khi privacyIDEA vẫn sống
Đây là một single point of failure thật sự. Giảm thiểu bằng cách khai nhiều DC trong LDAPURI (mục 3.3.1) và chuẩn bị tài khoản break-glass dùng otppin=tokenpin (mục 9.6).
Cấu hình khác nhau cho từng realm — ví dụ AD dùng userstore, nhà thầu dùng tokenpin:
sudo pi-manage config policy create auth_ad authentication "otppin=userstore"
# rồi vào WebUI Config -> Policies -> auth_ad -> đặt realm = lab
sudo pi-manage config policy create auth_ext authentication "otppin=tokenpin"
# realm = ext, priority = 1
4.3. Bộ policy nền tảng — chạy ngay
# ══ 1. Xác thực ════════════════════════════════════════════════
sudo pi-manage config policy create auth_userstore authentication "otppin=userstore"
# ══ 2. Enrollment — nhãn hiển thị trên app OTP ═════════════════
# {user} và {realm} là tag động. Dùng cả hai để tránh nhãn rỗng.
sudo pi-manage config policy create enroll_base enrollment \
"tokenlabel={user}@{realm},tokenissuer=LAB,max_active_token_per_user=2,verify_enrollment=hotp totp"
# ══ 3. Tự phục vụ — user tự đăng ký và quản lý token của mình ══
sudo pi-manage config policy create selfservice user \
"enrollTOTP,delete,disable,enable,resync,setpin,reset"
# ══ 4. WebUI ═══════════════════════════════════════════════════
sudo pi-manage config policy create webui_base webui \
"logout_time=900,jwt_validity=3600"
sudo pi-manage config policy list
Giải nghĩa từng action quan trọng:
| Action | Ý nghĩa |
|---|---|
tokenlabel={user}@{realm} |
Nhãn hiện trên app OTP. ⚠️ Nếu chỉ dùng {user} mà enroll không gắn user → QR code không hợp lệ |
tokenissuer=LAB |
Tên tổ chức hiện trên app |
max_active_token_per_user=2 |
⭐ Cho phép 2 token/user (điện thoại chính + dự phòng). Giảm mạnh ticket "mất điện thoại" |
verify_enrollment=hotp totp |
⭐ Rất nên bật. Bắt user nhập một mã đúng để xác nhận đã quét QR thành công, trước khi token được kích hoạt. Không có nó, user tưởng đã xong nhưng thực ra chưa quét được |
enrollTOTP |
Cho phép user tự đăng ký token TOTP |
resync |
Cho phép user tự đồng bộ lại token |
logout_time=900 |
Tự đăng xuất WebUI sau 15 phút |
4.4. Chống brute-force — bắt buộc phải có
Mã 6 chữ số với timeWindow mặc định 180 giây cho ra 12 mã hợp lệ cùng lúc. Không giới hạn số lần thử thì brute-force là khả thi.
Cấu hình qua WebUI: Config → Tokens → Token defaults
| Tham số | Giá trị khuyến nghị | Ý nghĩa |
|---|---|---|
| Max failcount | 6 |
Sai 6 lần → token bị khoá, cần admin mở |
| Count window | 10 |
Số bước quét khi tìm mã (HOTP) |
| Sync window | 1000 |
Cửa sổ resync (HOTP) |
| TOTP timeStep | 30 |
Giữ 30 giây |
| TOTP timeWindow | 60 |
⭐ Đơn vị GIÂY. Xem cách tính bên dưới — mặc định 180 cho 12 mã hợp lệ, siết xuống 60 còn 4 mã |
| TOTP hashlib | sha1 |
Giữ nguyên để tương thích mọi app |
Cách privacyIDEA thực sự tính window — công thức này khác với trực giác:
so_buoc = timeWindow / timeStep
server duyet counter trong khoang: [counter - so_buoc, counter + so_buoc)
=> so ma hop le ≈ 2 × so_buoc
timeWindow |
Số bước | Số mã hợp lệ cùng lúc | Dung sai đồng hồ |
|---|---|---|---|
| 180 giây (mặc định) | ±6 | 12 mã ⚠️ | ~±3 phút |
| 90 giây | ±3 | 6 mã | ~±1,5 phút |
| 60 giây ⭐ | ±2 | 4 mã | ~±1 phút |
| 30 giây | ±1 | 2 mã | ~±30 giây |
💡 Vì sao mặc định là 180 giây? Vì nó giảm ticket support. Nhưng nó cũng nhân sáu lần số mã hợp lệ so với mức tối thiểu. Nếu NTP của bạn chuẩn (mục 1.5.3), hạ xuống 60 giây là an toàn và vẫn thoải mái cho người dùng.
Thêm lớp bảo vệ ở tầng ứng dụng:
# Chặn xác thực từ ngoài dải mạng nội bộ
sudo pi-manage config policy create authz_network authorization "authorized=grant_access"
# rồi vào WebUI đặt client = 192.168.10.0/24
Fail2ban cho tầng HTTP — chặn IP quét mù:
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/filter.d/privacyidea.conf > /dev/null <<'EOF'
[Definition]
failregex = ^.*Failed to authenticate user .* from <HOST>.*$
^.*privacyIDEA.*authentication failed.*<HOST>.*$
ignoreregex =
EOF
sudo tee /etc/fail2ban/jail.d/privacyidea.conf > /dev/null <<'EOF'
[privacyidea]
enabled = true
port = https
filter = privacyidea
logpath = /var/log/privacyidea/privacyidea.log
maxretry = 10
findtime = 300
bantime = 1800
EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status privacyidea
📌 Regex trên là điểm khởi đầu — hãy đối chiếu với log thật của bạn bằng sudo tail -50 /var/log/privacyidea/privacyidea.log sau vài lần đăng nhập sai, rồi chỉnh lại cho khớp. Fail2ban với regex không khớp là fail2ban không làm gì cả.
4.5. Enroll token TOTP
4.5.1. Cách 1 — Admin enroll qua WebUI (dùng khi bàn giao trực tiếp)
Tokens → Enroll Token- Token type:
TOTP - Username: gõ tên user (có autocomplete từ resolver), chọn Realm
- PIN: đặt nếu dùng
otppin=tokenpin; để trống nếu dùngotppin=userstore - Tham số TOTP:
Length = 6,Timestep = 30,Hash = sha1— giữ mặc định - Bấm Enroll Token → hiện QR code
- Người dùng quét bằng app
- Nếu đã bật
verify_enrollment: nhập mã hiện trên app để xác nhận → token mới chuyển sang trạng thái hoạt động - Bấm serial token → Test Token để chắc chắn
4.5.2. Cách 2 — REST API (dùng cho tự động hoá)
export PI_URL="https://otp01.lab.local"
export PI_TOKEN=$(curl -s -k -X POST $PI_URL/auth \
-d "username=admin" -d "password=<mat-khau>" | jq -r '.result.value.token')
curl -s -k -X POST $PI_URL/token/init \
-H "PI-Authorization: $PI_TOKEN" \
-d "type=totp" \
-d "user=hoangnv" \
-d "realm=lab" \
-d "genkey=1" \
-d "otplen=6" \
-d "hashlib=sha1" \
-d "timeStep=30" \
-d "description=iPhone cua Hoang - cap 2026-08-01" | jq
Tham số quan trọng của /token/init:
| Tham số | Ý nghĩa |
|---|---|
type=totp |
Loại token |
genkey=1 |
⭐ Server tự sinh seed. Luôn dùng cái này — đừng tự sinh seed ở client rồi gửi lên |
otpkey=... |
Seed do bạn cung cấp (chỉ dùng khi nạp token cứng đã có seed sẵn) |
serial=... |
Serial tự đặt. Bỏ trống thì server tự sinh |
otplen, hashlib, timeStep |
6 / sha1 / 30 — giữ mặc định |
pin |
PIN của token (nếu dùng otppin=tokenpin) |
2stepinit=1 |
Enroll 2 bước: seed được tạo từ phần của server và phần của client |
rollover=1 |
Cấp seed mới cho token đã tồn tại |
Response chứa QR code:
{
"detail": {
"googleurl": {
"description": "URL for google Authenticator",
"img": "<img width=250 src=\"data:image/png;base64,iVBORw0KGgo...\"/>",
"value": "otpauth://totp/hoangnv@lab?secret=KRUGKIDROVUWG2ZA...&issuer=LAB&digits=6&period=30"
},
"otpkey": {"description": "OTP seed", "value": "seed://3132...3930"},
"serial": "TOTP0001A2B3",
"rollout_state": "verify"
},
"result": {"status": true, "value": true},
"version": "privacyIDEA 3.13.3"
}
Trích QR ra file PNG:
curl -s -k -X POST $PI_URL/token/init \
-H "PI-Authorization: $PI_TOKEN" \
-d "type=totp" -d "user=hoangnv" -d "realm=lab" -d "genkey=1" \
| jq -r '.detail.googleurl.img' \
| sed 's/.*base64,\([^"]*\)".*/\1/' \
| base64 -d > /tmp/qr_hoangnv.png
Xác nhận enrollment (khi đã bật verify_enrollment):
curl -s -k -X POST $PI_URL/token/init \
-H "PI-Authorization: $PI_TOKEN" \
-d "serial=TOTP0001A2B3" \
-d "verify=123456" | jq '.result'
4.5.3. Cách 3 — Người dùng tự đăng ký (self-service)
Đây là cách tốt nhất khi triển khai quy mô lớn: seed không bao giờ đi qua tay admin.
Điều kiện: đã có policy selfservice với action enrollTOTP (mục 4.3).
- Người dùng vào
https://otp01.lab.local/ - Đăng nhập bằng mật khẩu AD (chọn đúng realm)
Tokens → Enroll Token → TOTP- Quét QR, nhập mã xác nhận
- Xong
⭐ Đây là phương án nên dùng cho rollout diện rộng. Nó vừa giảm tải helpdesk, vừa an toàn hơn: admin không bao giờ nhìn thấy seed của người dùng.
Để tăng an toàn thêm, giới hạn self-service enrollment chỉ từ mạng nội bộ bằng cách đặt client = 192.168.10.0/24 cho policy selfservice.
4.6. ⚠️ Quy tắc bàn giao token — phần hay bị làm sai
🔴 Mã QR chứa seed ở dạng plaintext. Ai chụp được màn hình QR đó là sinh được OTP của người kia vĩnh viễn. Nó nguy hiểm ngang với mật khẩu.
Được phép:
- ✅ Người dùng tự enroll qua self-service portal (seed không rời server)
- ✅ Hiển thị QR trên màn hình khi người dùng có mặt trực tiếp
- ✅ Video call có xác minh danh tính, QR hiện trên màn hình chia sẻ, xoá ngay sau khi quét xong
Tuyệt đối không:
- ❌ Gửi QR qua email, Zalo, Teams, Slack
- ❌ Lưu file QR vào thư mục chung
- ❌ In QR ra giấy rồi để trên bàn
- ❌ Chụp màn hình QR "để phòng khi cần"
Nếu buộc phải xuất file QR, xoá an toàn ngay sau khi dùng:
shred -u /tmp/qr_hoangnv.png
App OTP miễn phí khuyến nghị cho người dùng:
| App | Nền tảng | License | Ghi chú |
|---|---|---|---|
| Aegis Authenticator | Android | GPLv3 | ⭐ Tốt nhất: vault mã hoá, export/import backup có mật khẩu |
| 2FAS | Android, iOS | GPLv3 | UI đẹp, backup lên iCloud/Drive đã mã hoá |
| Ente Auth | Android, iOS, Desktop | AGPLv3 | Đồng bộ đa thiết bị end-to-end encrypted |
| FreeOTP+ | Android | Apache 2.0 | Nhẹ, có backup |
| KeePassXC | Windows/macOS/Linux | GPLv2 | Chứa TOTP trong database KeePass — tiện cho admin |
| Google Authenticator | Android, iOS | Proprietary | Phổ biến nhất; chỉ hỗ trợ SHA1/6/30 |
| Microsoft Authenticator | Android, iOS | Proprietary | Hợp lý nếu đã dùng M365 |
💡 Với tài khoản đặc quyền (admin, lãnh đạo), cân nhắc token cứng: YubiKey hoặc Token2. Chúng không mất theo điện thoại và không bị ảnh hưởng khi người dùng đổi máy. privacyIDEA hỗ trợ sẵn Yubikey và FIDO2.
4.7. Test toàn bộ chuỗi xác thực
# ── Test 1: xác thực với PIN/mật khẩu + OTP ───────────────────
# Với otppin=userstore: pass = <mat-khau-AD><OTP>
curl -s -k -X POST $PI_URL/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD123456" | jq
Thành công:
{
"detail": {
"message": "matching 1 tokens",
"serial": "TOTP0001A2B3",
"type": "totp"
},
"result": {"status": true, "value": true},
"version": "privacyIDEA 3.13.3"
}
Đọc kết quả cho đúng — đây là chỗ rất dễ nhầm:
| Trường | Ý nghĩa |
|---|---|
result.status |
API có chạy được không (không phải kết quả xác thực!) |
result.value |
⭐ Kết quả xác thực thật. true = thành công, false = thất bại |
detail.message |
Lý do |
detail.serial |
Token nào đã khớp |
⚠️ Bẫy kinh điển khi viết code tích hợp: result.status = true chỉ nghĩa là "request hợp lệ và server xử lý xong". Một lần nhập sai OTP vẫn trả status: true kèm value: false. Luôn kiểm tra result.value.
# ── Test 2: mã sai phải bị từ chối ────────────────────────────
curl -s -k -X POST $PI_URL/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD000000" \
| jq '{value: .result.value, message: .detail.message}'
# -> {"value": false, "message": "wrong otp value..."}
# ── Test 3: chống replay — dùng lại đúng mã vừa dùng ──────────
# -> value: false. privacyIDEA từ chối mã đã dùng. Đây là TÍNH NĂNG.
# ── Test 4: endpoint dành cho RADIUS ──────────────────────────
curl -s -k -o /dev/null -w "HTTP %{http_code}\n" -X POST \
$PI_URL/validate/radiuscheck \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD123456"
# HTTP 204 = thành công (body rỗng)
# HTTP 400 = thất bại
Xem audit log:
# WebUI: Audit
# Hoặc API:
curl -s -k -G $PI_URL/audit/ \
-H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "page_size=10" \
| jq '.result.value.auditdata[] | {date, action, success, user, serial, info}'
4.8. Tóm tắt Phần 4
Các bước chính:
- Hiểu policy engine: 11 scope, cộng dồn,
prioritysố nhỏ thắng, scope trống = cho phép tất cả. - Đặt
otppin— quyết định lớn nhất:userstore(dùng mật khẩu AD) hoặctokenpin(PIN riêng). - Tạo bộ policy nền:
enroll_base(cóverify_enrollmentvàmax_active_token_per_user=2),selfservice,webui_base. - Siết
Max failcount = 6vàtimeWindow = 60giây; thêm fail2ban. - Enroll token: ưu tiên self-service, seed không qua tay admin.
- Bàn giao token đúng quy tắc: không bao giờ gửi QR qua email/chat.
- Test bằng
/validate/checkvà nhớ kiểmresult.value, không phảiresult.status.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
| Mã trên app luôn sai | Lệch giờ giữa server và điện thoại | chronyc tracking; trong Google Authenticator: ⋮ → Time correction → Sync now; tạm nới timeWindow để xác nhận rồi sửa NTP và siết lại |
| QR code không hợp lệ, app từ chối quét | tokenlabel={user} mà enroll không gắn user → nhãn rỗng |
Đổi thành tokenlabel={user}@{realm} |
| Xác thực fail dù mã đúng | Sai otppin: đang dùng userstore mà user nhập PIN token |
Kiểm tra policy đang áp dụng cho realm đó |
| Token bị khoá | Vượt Max failcount |
WebUI → chọn token → Reset Failcounter, hoặc API POST /token/reset |
Sau khi tạo policy admin đầu tiên, admin mất quyền |
Scope admin trống = cho phép tất cả; có policy = chỉ cho những gì liệt kê |
Sửa policy thêm action còn thiếu, hoặc pi-manage config policy disable <ten> |
| User tự enroll được nhưng không đăng nhập được | Chưa bật verify_enrollment nên token còn ở trạng thái clientwait |
Bật verify_enrollment=hotp totp |
otppin=userstore fail toàn bộ |
DC không tới được — mật khẩu được kiểm bằng LDAP bind trực tiếp | Kiểm tra ldapsearch; khai nhiều DC trong LDAPURI |
| Bảng challenge phình to | Từ 3.10 challenge không tự dọn | Bật cron pi-manage config challenge cleanup --age 60 |
| Code tích hợp coi mọi request là thành công | Kiểm result.status thay vì result.value |
Sửa code — xem cảnh báo ở 4.7 |
PHẦN 5. FREERADIUS — CẦU NỐI TỚI MỌI CLIENT
5.1. Vì sao cần RADIUS khi đã có REST API
privacyIDEA có REST API rất tốt, nhưng thiết bị hạ tầng không nói REST. Firewall, VPN gateway, switch, wireless controller, NAS — tất cả đều nói RADIUS. Linux pam_radius cũng vậy.
Windows Credential Provider và web app gọi thẳng REST API — nhanh hơn và an toàn hơn. RADIUS chỉ dùng cho những gì không có lựa chọn khác.
⚠️ RADIUS là giao thức cũ và yếu. Trường password chỉ được che bằng MD5 + shared secret. Vì vậy:
- Không bao giờ để RADIUS đi qua Internet trần
- Đặt OTP server và client trong cùng VLAN quản trị, hoặc bọc trong IPsec/WireGuard
- Mỗi thiết bị một shared secret riêng, tối thiểu 24 ký tự ngẫu nhiên
- Cấu hình require_message_authenticator đúng cách (liên quan CVE-2024-3596 / BlastRADIUS) — xem cảnh báo quan trọng ở mục 5.5.4, đặt sai giá trị sẽ chặn đứng toàn bộ SSH
5.2. Cài FreeRADIUS và các phụ thuộc
sudo apt update
sudo apt install -y freeradius freeradius-utils
🔴 KHÔNG có gói freeradius-perl hay libfreeradius-perl trong Ubuntu noble. Nếu bạn thấy hướng dẫn nào bảo cài chúng, hướng dẫn đó sai. rlm_perl.so nằm sẵn trong gói freeradius chính, tại /usr/lib/freeradius/rlm_perl.so.
Kiểm tra:
ls -l /usr/lib/freeradius/rlm_perl.so
freeradius -v # Ubuntu 24.04: FreeRADIUS 3.2.5
Cài các Perl module — danh sách này lấy từ cpanfile của plugin, chính xác hơn tài liệu:
# liburi-encode-perl nằm trong repo universe
sudo add-apt-repository -y universe
sudo apt update
sudo apt install -y \
libwww-perl \
liblwp-protocol-https-perl \
libconfig-inifiles-perl \
libdata-dump-perl \
libtry-tiny-perl \
libjson-perl \
liburi-encode-perl
| Perl module | Gói Ubuntu | Vai trò |
|---|---|---|
LWP |
libwww-perl |
HTTP client |
LWP::Protocol::https |
liblwp-protocol-https-perl |
⭐ Thiếu là không gọi được HTTPS |
Config::IniFiles |
libconfig-inifiles-perl |
Đọc file .ini |
Data::Dump |
libdata-dump-perl |
Debug output |
Try::Tiny |
libtry-tiny-perl |
Xử lý exception |
JSON |
libjson-perl |
Parse response |
URI::Encode |
liburi-encode-perl |
⭐ Tài liệu chính thức QUÊN liệt kê module này, nhưng code dùng nó → thiếu là plugin chết ngay |
Kiểm tra tất cả đã cài được:
perl -e 'use LWP; use LWP::Protocol::https; use Config::IniFiles; use Data::Dump; use Try::Tiny; use JSON; use URI::Encode; print "Tat ca Perl module OK\n";'
5.3. Cài plugin privacyIDEA cho FreeRADIUS
Kiểm tra xem có gói .deb cho noble không:
apt-cache policy privacyidea-radius
Nếu có gói, cài bằng apt:
sudo apt install -y privacyidea-radius
🔴 Nếu chọn đường cài bằng gói, HÃY BỎ QUA mục 5.5.1 và 5.5.2.
Script postinst của gói privacyidea-radius tự làm những việc sau:
- Tạo symlink mods-enabled/mods-perl-privacyidea → instance đã tên là perl-privacyidea
- Chạy rm -f /etc/freeradius/3.0/sites-enabled/* rồi enable site riêng tên privacyidea
- Xoá mods-enabled/eap
Nếu bạn làm tiếp mục 5.5.1, bạn sẽ tạo instance trùng tên → FreeRADIUS báo duplicate module và không khởi động được. Còn mục 5.5.2 sửa sites-available/default thì vô tác dụng vì site default đã bị disable.
Với đường cài bằng gói: chỉ cần làm mục 5.4 (file ini) và 5.5.4 (clients.conf), rồi nhảy sang 5.6 để test.
Cài thủ công (khi không có gói cho noble, hoặc muốn kiểm soát hoàn toàn):
sudo install -d -m 755 /usr/share/privacyidea/freeradius
sudo curl -fsSL -o /usr/share/privacyidea/freeradius/privacyidea_radius.pm \
https://raw.githubusercontent.com/privacyidea/FreeRADIUS/master/privacyidea_radius.pm
sudo chown root:root /usr/share/privacyidea/freeradius/privacyidea_radius.pm
sudo chmod 644 /usr/share/privacyidea/freeradius/privacyidea_radius.pm
# Kiểm tra cú pháp Perl
sudo perl -c /usr/share/privacyidea/freeradius/privacyidea_radius.pm
📌 Đường dẫn /usr/share/privacyidea/freeradius/privacyidea_radius.pm là đường dẫn chính thức theo file cấu hình mẫu trong repo. Nhiều hướng dẫn trên mạng bảo đặt vào /etc/freeradius/3.0/mods-config/perl/ — đó là sai.
⭐ Dùng plugin phiên bản ≥ 3.4.3. Bản này thêm Message-Authenticator vào reply để giảm thiểu CVE-2024-3596 (BlastRADIUS).
5.4. Cấu hình rlm_perl.ini
🔴 Đường dẫn file này KHÔNG phải /etc/freeradius/3.0/rlm_perl.ini.
Source code tìm file theo thứ tự sau:
1. Đường dẫn chỉ định trong block config { configfile = ... } (nếu có, chỉ dùng cái này)
2. /etc/privacyidea/rlm_perl.ini ⭐ khuyến nghị
3. /etc/freeradius/rlm_perl.ini
4. /opt/privacyIDEA/rlm_perl.ini
Trên Ubuntu 24.04, thư mục config của FreeRADIUS là /etc/freeradius/3.0/ — nên /etc/freeradius/rlm_perl.ini (thư mục cha) hợp lệ nhưng lệch chỗ. Dùng /etc/privacyidea/rlm_perl.ini.
🔴 TUYỆT ĐỐI KHÔNG chạy install -d ... /etc/privacyidea.
Lệnh install -d đổi owner và mode của thư mục kể cả khi nó đã tồn tại. Thư mục /etc/privacyidea đã được postinst đặt owner privacyidea ở Phần 2, và Apache chạy WSGI dưới user đó. Đổi thành root:freerad 0750 khiến user privacyidea không traverse được thư mục → không đọc được pi.cfg và enckey → toàn bộ privacyIDEA chết với HTTP 500.
Chỉ tạo file, không đụng vào thư mục:
sudo tee /etc/privacyidea/rlm_perl.ini > /dev/null <<'EOF'
[Default]
# Endpoint xác thực của privacyIDEA
URL = https://otp01.lab.local/validate/check
# Kiểm tra chứng chỉ TLS.
# ⚠️ Code so khớp bằng regex /true/i -> PHẢI ghi đúng chữ "true".
# Ghi "1", "yes", "TRUE" đều KHÔNG có tác dụng và SSL sẽ KHÔNG được verify.
SSL_CHECK = true
SSL_CA_PATH = /etc/ssl/certs
# Timeout HTTP (giây)
TIMEOUT = 15
# Log chi tiết — bật khi debug, TẮT khi production
DEBUG = false
# Ghi đè realm mà FreeRADIUS tách từ username. Để trống = giữ nguyên.
#REALM =
# Chỉ định resolver cụ thể. Để trống = tìm trong toàn realm.
#RESCONF =
# Gửi pass="" khi request không có User-Password (dùng cho token PUSH)
#ADD_EMPTY_PASS = false
# RADIUS attribute dùng làm tham số "client" gửi tới privacyIDEA
#CLIENTATTRIBUTE =
# ══ Ánh xạ thông tin từ response privacyIDEA sang RADIUS attribute ══
[Mapping]
serial = privacyIDEA-Serial
[Mapping user]
group = Class
EOF
sudo chown root:freerad /etc/privacyidea/rlm_perl.ini
sudo chmod 0640 /etc/privacyidea/rlm_perl.ini
Bảng đầy đủ các key trong [Default]:
| Key | Mặc định | Ý nghĩa |
|---|---|---|
URL |
https://127.0.0.1/validate/check |
Endpoint privacyIDEA |
REALM |
'' |
Nếu đặt, ghi đè realm mà FreeRADIUS tách từ username |
RESCONF |
'' |
Giới hạn tìm user trong một resolver |
DEBUG |
FALSE |
⚠️ Khớp regex /true/i — phải ghi true |
SSL_CHECK |
FALSE |
⚠️ Tương tự. Mặc định KHÔNG verify certificate |
SSL_CA_PATH |
(không đặt) | Thư mục CA. Để trống + SSL_CHECK=true → dùng CA hệ thống |
TIMEOUT |
10 |
Giây |
SPLIT_NULL_BYTE |
FALSE |
Cắt password tại null byte |
ADD_EMPTY_PASS |
FALSE |
Gửi pass="" khi không có password |
CLIENTATTRIBUTE |
'' |
RADIUS attribute dùng làm tham số client |
Thứ tự xác định tham số client gửi tới privacyIDEA: NAS-IP-Address → Packet-Src-IP-Address → attribute do CLIENTATTRIBUTE chỉ định (ghi đè cuối cùng).
Cài dictionary của NetKnights (cần cho attribute privacyIDEA-Serial):
sudo curl -fsSL -o /usr/share/freeradius/dictionary.netknights \
https://raw.githubusercontent.com/privacyidea/FreeRADIUS/master/dictionary.netknights
# ⭐ PHẢI dùng ĐƯỜNG DẪN TUYỆT ĐỐI
echo '$INCLUDE /usr/share/freeradius/dictionary.netknights' \
| sudo tee -a /etc/freeradius/3.0/dictionary
sudo freeradius -XC # kiểm tra cấu hình, phải in "Configuration appears to be OK"
🔴 Vì sao bắt buộc đường dẫn tuyệt đối: $INCLUDE với đường dẫn tương đối được FreeRADIUS resolve theo thư mục của file đang include — tức /etc/freeradius/3.0/. Nếu bạn ghi $INCLUDE dictionary.netknights trong khi file nằm ở /usr/share/freeradius/, FreeRADIUS tìm /etc/freeradius/3.0/dictionary.netknights, không thấy, và radiusd thoát ngay khi khởi động với lỗi Errors reading dictionary.
5.5. Cấu hình FreeRADIUS
📌 Mục 5.5.1 và 5.5.2 CHỈ dành cho đường cài thủ công. Nếu bạn đã apt install privacyidea-radius, hãy bỏ qua hai mục này (xem cảnh báo ở 5.3).
5.5.1. Tạo instance module riêng (chỉ khi cài thủ công)
⚠️ Đừng sửa file /etc/freeradius/3.0/mods-available/perl mặc định. Tạo instance riêng để nâng cấp FreeRADIUS không ghi đè cấu hình của bạn.
sudo tee /etc/freeradius/3.0/mods-available/privacyidea > /dev/null <<'EOF'
# Instance rlm_perl riêng cho privacyIDEA
perl perl-privacyidea {
filename = /usr/share/privacyidea/freeradius/privacyidea_radius.pm
# Chỉ định tường minh đường dẫn file ini
config {
configfile = "/etc/privacyidea/rlm_perl.ini"
}
}
EOF
sudo ln -sf ../mods-available/privacyidea /etc/freeradius/3.0/mods-enabled/privacyidea
ls -l /etc/freeradius/3.0/mods-enabled/privacyidea
📌 Hai chi tiết kỹ thuật quan trọng:
- Không khai
perl_flags. Mẫu chính thức của privacyIDEA cố ý bỏ nó. Fileperlmặc định của FreeRADIUS cóperl_flags = "-T"(taint mode) — copy sang đây sẽ gây lỗi với LWP. - Dùng
filename, không dùngmodule. Keymoduleđã deprecated. Tài liệu cũ viết cho FreeRADIUS 2 dùngmodule = ...— sai với FreeRADIUS 3.
5.5.2. Sửa virtual server default (chỉ khi cài thủ công)
sudo cp /etc/freeradius/3.0/sites-available/default \
/etc/freeradius/3.0/sites-available/default.bak-$(date +%F)
sudo nano /etc/freeradius/3.0/sites-available/default
Trong section authorize { } — thêm vào cuối, sau files:
# ── privacyIDEA ────────────────────────────────────────────
update request {
# Fallback cho tham số client khi không có NAS-IP-Address
Packet-Src-IP-Address = "%{Packet-Src-IP-Address}"
}
perl-privacyidea
if (ok || updated) {
update control {
Auth-Type := Perl
}
}
Trong section authenticate { } — thêm trước pap:
Auth-Type Perl {
perl-privacyidea
}
💡 Chi tiết đáng biết: hàm authorize trong module Perl thực ra không làm gì cả — nó chỉ return RLM_MODULE_OK. Nó tồn tại để nhánh if (ok || updated) đặt được Auth-Type := Perl. Bạn hoàn toàn có thể bỏ perl-privacyidea khỏi authorize và đặt Auth-Type := Perl trực tiếp — kết quả giống nhau.
Section post-auth { } — giữ nguyên Post-Auth-Type Challenge mặc định:
🔴 Đừng xoá section Post-Auth-Type Challenge. Module đặt Response-Packet-Type = Access-Challenge để hỗ trợ challenge-response (token PUSH, SMS OTP, Email OTP, enroll 2 bước). Nếu bạn cắt gọt site config và bỏ mất section này, challenge-response sẽ hỏng im lặng.
5.5.3. inner-tunnel — chỉ khi dùng EAP
Nếu bạn dùng RADIUS thuần (VPN, SSH, firewall), bỏ qua mục này.
Nếu dùng 802.1X / PEAP / EAP-TTLS, lặp lại cấu hình trên trong /etc/freeradius/3.0/sites-available/inner-tunnel.
⚠️ Giới hạn kiến trúc quan trọng: privacyIDEA nhận OTP dưới dạng password plaintext. Do đó nó chỉ hoạt động với inner method PAP (PEAP-PAP hoặc TTLS-PAP). MSCHAPv2 không dùng được với OTP — vì server cần plaintext password để tính hash, mà OTP thì thay đổi mỗi 30 giây nên không có gì để tính trước.
Đây là hạn chế của thiết kế, không phải lỗi cấu hình. Nếu hạ tầng 802.1X của bạn bắt buộc MSCHAPv2, OTP không phải giải pháp cho lớp đó — hãy dùng certificate (EAP-TLS) thay thế.
5.5.4. Khai báo RADIUS client
sudo nano /etc/freeradius/3.0/clients.conf
# ══ Sinh secret ngẫu nhiên: openssl rand -base64 32 ═══════════
client lnx01 {
ipaddr = 192.168.10.31
secret = 'THAY_BANG_CHUOI_NGAU_NHIEN_32_KY_TU_1'
shortname = lnx01
nas_type = other
# ⚠️ pam_radius 2.0.1 (bản trong Ubuntu noble) KHÔNG gửi Message-Authenticator.
# Đặt "yes" ở đây = mọi request SSH bị vứt im lặng. Dùng "auto".
require_message_authenticator = auto
}
client web01 {
ipaddr = 192.168.10.41
secret = 'THAY_BANG_CHUOI_NGAU_NHIEN_32_KY_TU_2'
shortname = web01
require_message_authenticator = auto
}
client vpn-gateway {
ipaddr = 192.168.10.1
secret = 'THAY_BANG_CHUOI_NGAU_NHIEN_32_KY_TU_3'
shortname = vpn-gw
nas_type = other
# Thiết bị mạng đã vá BlastRADIUS thì đặt "yes" được
require_message_authenticator = auto
}
🔴 Bẫy rất khó chẩn đoán — đọc kỹ trước khi đặt yes:
require_message_authenticator = yes khiến FreeRADIUS vứt bỏ im lặng mọi Access-Request không có attribute Message-Authenticator.
Ubuntu 24.04 ship libpam-radius-auth 2.0.1, và mitigation BlastRADIUS (thêm Message-Authenticator vào request) chỉ có từ pam_radius 3.0.0. Nghĩa là nếu bạn đặt yes cho client Linux, toàn bộ SSH sẽ timeout rồi Permission denied — mà log RADIUS không hiện gì cả vì gói bị vứt trước khi xử lý.
Điều làm bẫy này đặc biệt nguy hiểm: radtest và radclient của FreeRADIUS 3.2.x tự động thêm Message-Authenticator. Nên bài test ở mục 5.6 vẫn PASS hoàn hảo, trong khi SSH thật thì chết. Bạn sẽ đi debug PAM hàng giờ trong khi lỗi nằm ở clients.conf.
| Giá trị | Hành vi | Dùng khi |
|---|---|---|
auto |
⭐ Yêu cầu nếu client từng gửi, không bắt buộc nếu chưa | Mặc định an toàn cho lab này |
yes |
Bắt buộc — vứt request không có | Chỉ khi đã xác nhận client gửi (radclient, thiết bị đã vá) |
no |
Không yêu cầu | Kèm limit_proxy_state = yes để bù |
Khi nào Ubuntu cập nhật libpam-radius-auth lên ≥ 3.0.0, hãy chuyển các client Linux sang yes. Kiểm tra bằng: apt-cache policy libpam-radius-auth
Sinh secret ngẫu nhiên:
for i in 1 2 3; do echo "Secret $i: $(openssl rand -base64 32)"; done
Siết quyền file — nó chứa toàn bộ shared secret:
sudo chown root:freerad /etc/freeradius/3.0/clients.conf
sudo chmod 0640 /etc/freeradius/3.0/clients.conf
⚠️ Xoá hoặc comment client localhost mặc định nếu bạn không dùng nó — nó có secret testing123 mà cả thế giới đều biết. Giữ lại chỉ khi cần test cục bộ, và nên đổi secret.
5.6. Test FreeRADIUS
5.6.1. Chạy ở chế độ debug (bắt buộc làm trước)
sudo systemctl stop freeradius
sudo freeradius -X
Đọc kỹ output khi khởi động. Bạn phải thấy:
Module: Linked to module rlm_perl
Module: Instantiating module "perl-privacyidea" from file /etc/freeradius/3.0/mods-enabled/privacyidea
Nếu có lỗi Perl, nó sẽ hiện ngay ở đây.
Từ terminal thứ hai, gửi request thử:
# Cú pháp: radtest <user> <password> <server[:port]> <nas_port> <secret>
radtest hoangnv 'MatKhauAD123456' 127.0.0.1:1812 0 'testing123'
Kết quả mong đợi:
Sent Access-Request Id 42 from 0.0.0.0:54321 to 127.0.0.1:1812 length 78
User-Name = "hoangnv"
User-Password = "MatKhauAD123456"
Received Access-Accept Id 42 from 127.0.0.1:1812 to 127.0.0.1:54321 length 20
Trong output của freeradius -X, tìm dòng chẩn đoán quan trọng nhất:
Config File /etc/privacyidea/rlm_perl.ini found!
🔍 Đây là dòng vàng khi debug. Module in nó ở đầu mỗi request:
- Config File <path> found! → đọc được file ini, cấu hình của bạn đang có hiệu lực
- Config File <path> not found! → không đọc được → module đang chạy với giá trị mặc định hardcode (URL = https://127.0.0.1/validate/check, SSL_CHECK = FALSE)
Nếu thấy not found, kiểm tra đường dẫn và quyền file trước khi debug bất cứ thứ gì khác.
Các dòng log hữu ích khác từ module: user sent to privacyidea:, realm sent to privacyidea:, client sent to privacyidea:, elapsed time for privacyidea call:.
5.6.2. Khởi động dịch vụ
sudo systemctl start freeradius
sudo systemctl enable freeradius
sudo systemctl status freeradius
sudo ss -ulnp | grep -E ':1812|:1813'
5.6.3. Test từ máy khác
# Trên LNX01
sudo apt install -y freeradius-utils
radtest hoangnv 'MatKhauAD123456' 192.168.10.10:1812 0 'SECRET_CUA_LNX01'
Bắt gói để nhìn tận mắt:
sudo tcpdump -i any -n -vv 'udp port 1812'
Debug sâu hơn:
echo "User-Name=hoangnv,User-Password=MatKhauAD123456" \
| radclient -x 192.168.10.10:1812 auth 'SECRET_CUA_LNX01'
5.6.4. Script health-check cho monitoring
sudo tee /usr/local/bin/check-radius.sh > /dev/null <<'EOF'
#!/bin/bash
# Kiểm tra RADIUS server có PHẢN HỒI hay không.
# Lưu ý: Access-Reject vẫn chứng minh server sống VÀ shared secret đúng.
# Chỉ TIMEOUT mới là sự cố thật.
SERVER="${1:-127.0.0.1}"
SECRET="${2:-testing123}"
OUT=$(echo "User-Name=healthcheck,User-Password=000000" \
| radclient -t 5 -r 1 "$SERVER:1812" auth "$SECRET" 2>&1)
if echo "$OUT" | grep -qE "Access-(Accept|Reject|Challenge)"; then
echo "OK - RADIUS $SERVER dang phan hoi"; exit 0
else
echo "CRITICAL - RADIUS $SERVER KHONG phan hoi: $OUT"; exit 2
fi
EOF
sudo chmod +x /usr/local/bin/check-radius.sh
/usr/local/bin/check-radius.sh
💡 Điểm tinh tế: đừng viết monitoring đòi hỏi Access-Accept — bạn sẽ phải nhét một OTP thật vào script, điều đó tệ hơn nhiều. Access-Reject đã đủ chứng minh server sống và secret đúng.
5.7. Lựa chọn thay thế: rlm_rest
privacyIDEA cũng hỗ trợ rlm_rest gọi thẳng endpoint /validate/radiuscheck. Nhưng hãy đọc bảng này trước khi chọn:
| Tiêu chí | rlm_perl ⭐ |
rlm_rest |
|---|---|---|
| Endpoint | /validate/check |
/validate/radiuscheck |
| Challenge-Response (PUSH, SMS/Email OTP) | ✅ Có | ❌ KHÔNG |
| Attribute mapping (Class, Filter-Id) | ✅ Có | ❌ KHÔNG |
| Tham số realm/resolver | ✅ Có sẵn | 🟡 Phải tự nhét vào data |
| Hiệu năng | Perl interpreter nhúng, chậm hơn | C + libcurl, nhanh hơn |
| Phụ thuộc | 7 Perl module | Chỉ freeradius-rest |
| Thread safety | Có tiền sử vấn đề với Perl deps cũ | Tốt |
Tài liệu chính thức của privacyIDEA nói rõ: endpoint radiuscheck "hiện chưa implement đầy đủ tính năng của rlm_perl, ví dụ challenge-response và attribute mapping".
Kết luận: dùng rlm_perl trừ khi bạn chắc chắn chỉ dùng token nhập-một-lần (TOTP/HOTP/Yubikey) và không cần trả attribute về cho NAS.
Nếu vẫn muốn dùng rlm_rest:
sudo apt install -y freeradius-rest
sudo ln -sf ../mods-available/rest /etc/freeradius/3.0/mods-enabled/rest
sudo nano /etc/freeradius/3.0/mods-enabled/rest
rest {
tls {
ca_path = /etc/ssl/certs
check_cert = yes
check_cert_cn = yes
}
connect_uri = "https://otp01.lab.local/"
authenticate {
uri = "${..connect_uri}/validate/radiuscheck"
method = 'post'
body = 'post'
data = "user=%{urlquote:%{User-Name}}&pass=%{urlquote:%{User-Password}}"
force_to = 'plain'
tls = ${..tls}
}
}
⚠️ connect_uri được ping lúc khởi động — FreeRADIUS sẽ không start nếu privacyIDEA không truy cập được. Đây là hành vi khác với rlm_perl, và có thể gây bất ngờ khi khởi động lại cả hai dịch vụ cùng lúc.
5.8. Tóm tắt Phần 5
Các bước chính:
apt install freeradius freeradius-utils— không có góifreeradius-perl,rlm_perl.sonằm trong gói chính.- Cài 7 Perl module, nhớ
liburi-encode-perl(tài liệu chính thức quên liệt kê). - Đặt module tại
/usr/share/privacyidea/freeradius/privacyidea_radius.pm, phiên bản ≥ 3.4.3. - Đặt ini tại
/etc/privacyidea/rlm_perl.ini,SSL_CHECK = true(chữ thường, đúng chữ "true"). - Tạo instance
perl perl-privacyideariêng, không sửa fileperlmặc định, không thêmperl_flags. - Sửa
authorize{}vàauthenticate{}của sitedefault; giữ nguyênPost-Auth-Type Challenge. - Khai client với secret riêng ≥ 24 ký tự và
require_message_authenticator = auto(đặtyessẽ chặn đứng SSH vìlibpam-radius-auth2.0.1 của noble không gửi attribute đó). - Test bằng
freeradius -X, tìm dòngConfig File ... found!.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
Can't locate URI/Encode.pm in @INC |
Thiếu liburi-encode-perl — tài liệu chính thức không liệt kê |
add-apt-repository universe && apt install liburi-encode-perl |
Can't locate Config/IniFiles.pm |
Thiếu Perl module | Cài đủ 7 gói ở mục 5.2 |
Config File ... not found! trong log |
Sai đường dẫn ini hoặc sai quyền | Đặt /etc/privacyidea/rlm_perl.ini, hoặc khai configfile tường minh; chown root:freerad, chmod 640 |
501 Protocol scheme 'https' is not supported |
Thiếu liblwp-protocol-https-perl |
Cài gói đó |
certificate verify failed |
CA chưa được tin cậy | SSL_CA_PATH=/etc/ssl/certs; hoặc copy CA vào /usr/local/share/ca-certificates/ rồi update-ca-certificates |
| SSL không được verify dù đã bật | Ghi SSL_CHECK = 1 thay vì true |
Code khớp regex /true/i — phải ghi đúng true |
Failed to link to module 'rlm_perl' |
Hiếm trên noble | ls /usr/lib/freeradius/rlm_perl.so; cài lại gói freeradius |
| Token PUSH/SMS không hoạt động | Dùng rlm_rest, hoặc site thiếu Post-Auth-Type Challenge |
Chuyển sang rlm_perl và khôi phục section đó |
| MSCHAPv2 luôn fail | Giới hạn kiến trúc — không có plaintext password | Chuyển sang PAP, hoặc dùng EAP-TLS cho lớp 802.1X |
| Segfault / lỗi ngẫu nhiên dưới tải | Perl deps không thread-safe | Chạy freeradius -X -t (single thread) để xác nhận; cập nhật Perl module |
Access-Reject ngay lập tức |
Sai shared secret (thường do dấu cách thừa cuối dòng) | cat -A clients.conf để thấy ký tự ẩn; so từng ký tự |
| Timeout, không phản hồi | Firewall, hoặc IP client chưa khai trong clients.conf |
tcpdump -n udp port 1812 ở cả hai đầu; ufw status |
🔴 radtest PASS nhưng SSH thật luôn timeout |
require_message_authenticator = yes + libpam-radius-auth 2.0.1 không gửi attribute đó. radtest tự thêm nên vẫn pass |
Đổi thành auto trong clients.conf. Đây là bẫy khó chẩn đoán nhất của Phần 5 |
| FreeRADIUS không khởi động sau khi thêm dictionary | $INCLUDE dùng đường dẫn tương đối |
Dùng đường dẫn tuyệt đối; freeradius -XC để kiểm tra |
| FreeRADIUS báo duplicate module | Đã apt install privacyidea-radius rồi còn làm tiếp 5.5.1 |
Chọn một trong hai đường, không làm cả hai |
PHẦN 6. CLIENT WINDOWS — RDP VÀ CONSOLE LOGON
6.1. Cách hoạt động
privacyIDEA Credential Provider thay thế màn hình đăng nhập của Windows, chèn thêm bước nhập OTP. Nó gọi thẳng REST API qua HTTPS — không qua RADIUS.
Phiên bản: 3.8.0 (26/02/2026)
Hỗ trợ: Windows 10, 11, Server 2016/2019/2022
Tải: https://github.com/privacyidea/privacyidea-credential-provider/releases
📌 Windows Server 2025 không được liệt kê trong tài liệu chính thức của NetKnights. Không có nghĩa là chắc chắn không chạy, nhưng cũng không có xác nhận. Nếu môi trường của bạn có Server 2025, hãy test trên một máy không quan trọng trước, hoặc hỏi NetKnights.
6.2. ⚠️ Chuẩn bị — đọc trước khi cài
🔴 BA VIỆC BẮT BUỘC LÀM TRƯỚC KHI CÀI:
1. Chuẩn bị tài khoản break-glass và test nó.
PS> $pw = ConvertTo-SecureString (-join ((33..126) | Get-Random -Count 28 | %{[char]$_})) -AsPlainText -Force
PS> New-LocalUser -Name "brk-glass" -Password $pw -PasswordNeverExpires `
-Description "Break-glass - KHONG dung hang ngay"
PS> Add-LocalGroupMember -Group "Administrators" -Member "brk-glass"
Ghi mật khẩu vào password manager của công ty. Đăng nhập thử bằng tài khoản này ngay bây giờ để chắc chắn nó hoạt động.
2. Đảm bảo có quyền truy cập console qua hypervisor (vCenter / Hyper-V Manager / iDRAC / iLO). Nếu Credential Provider lỗi, đây là đường vào duy nhất.
3. Cài CA nội bộ vào Trusted Root của client. Nếu chứng chỉ của OTP01 không được tin cậy, CP sẽ không kết nối được — và bạn sẽ phải tắt kiểm tra TLS, điều đó mở đường cho MITM chặn được cả mật khẩu lẫn OTP.
PS> Import-Certificate -FilePath \\dc01\Software$\lab-ca.cer -CertStoreLocation Cert:\LocalMachine\Root
Hoặc đẩy qua GPO: Computer Configuration → Policies → Windows Settings → Security Settings → Public Key Policies → Trusted Root Certification Authorities.
Kiểm tra kết nối từ client trước khi cài:
PS> Test-NetConnection otp01.lab.local -Port 443
PS> Invoke-WebRequest -Uri "https://otp01.lab.local/" -UseBasicParsing | Select-Object StatusCode
# Không được có cảnh báo certificate nào
6.3. Cài đặt
Tải MSI 64-bit từ trang releases, chạy với quyền Administrator.
Trong wizard bạn cấu hình được các tuỳ chọn chính (hostname, path, filter, scenario). Các tuỳ chọn ít dùng hơn không có trong installer và phải đặt qua registry sau.
Cài thủ công (không dùng MSI) — hữu ích khi cần hiểu cơ chế hoặc tự động hoá:
CLSID chính thức:
| Thành phần | CLSID |
|---|---|
| Credential Provider | {7BAF541E-F8E0-4EDF-B69A-BD2771139E8E} |
| Filter | {34065473-D75F-4BC2-9782-E98E63ED0D41} |
- Copy
PrivacyIDEACredentialProvider.dll(vàPrivacyIDEACredentialProviderFilter.dll) vào%windir%\System32 - Tạo key
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{7BAF541E-...}, giá trị mặc định =PrivacyIDEACredentialProvider - Tại
HKEY_CLASSES_ROOT\CLSID\{7BAF541E-...}\InprocServer32: giá trị mặc định =PrivacyIDEACredentialProvider.dll, thêm REG_SZThreadingModel=Apartment - Lặp lại với CLSID Filter tại
...\Authentication\Credential Provider Filters\
Repo có sẵn RegisterProvider.reg, RegisterFilter.reg và các file Unregister tương ứng.
6.4. Cấu hình qua Registry
🔴 Đường dẫn registry — chú ý dấu cách và chữ GmbH:
HKEY_LOCAL_MACHINE\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP
Không phải HKLM\SOFTWARE\Netknights\PrivacyIDEA-CP. Đặt sai chỗ thì mọi cấu hình của bạn bị bỏ qua im lặng.
Kiểu dữ liệu mặc định là REG_SZ cho tất cả (trừ vài ngoại lệ ghi rõ bên dưới). Giá trị boolean là chuỗi "1", không phải DWORD.
6.4.1. Kết nối
| Value | Ý nghĩa | Giá trị gợi ý |
|---|---|---|
hostname |
Hostname server, không kèm path | otp01.lab.local |
path |
Path phụ nếu có | / |
custom_port |
Port khác 443 | (bỏ trống) |
ssl_ignore_unknown_ca |
1 = bỏ qua CA không tin cậy |
⚠️ Chỉ khi test |
ssl_ignore_invalid_cn |
1 = bỏ qua CN không khớp |
⚠️ Chỉ khi test |
resolve_timeout |
ms. Mặc định vô hạn | 5000 |
connect_timeout |
ms. Mặc định 60 giây | 10000 |
send_timeout |
ms. Mặc định 30 giây | 10000 |
receive_timeout |
ms. Mặc định 30 giây | 10000 |
user_agent_hide_computer_name |
1 = không gửi tên máy trong User-Agent |
1 |
Failover — CP hỗ trợ server dự phòng native:
| Value | Ý nghĩa |
|---|---|
fallback_hostname |
Hostname dự phòng (OTP02). Chỉ cần đặt cái này là kích hoạt |
fallback_path |
Path dự phòng |
fallback_port |
Port dự phòng |
Khi đã chuyển sang fallback, CP dùng nó suốt phiên xác thực đó.
6.4.2. Loại trừ khỏi 2FA
| Value | Ý nghĩa | Định dạng |
|---|---|---|
excluded_account |
⭐ Tài khoản break-glass | Bắt buộc domain\username hoặc computername\username |
excluded_group |
Group được miễn | Tên group local hoặc global |
excluded_group_netbios_address |
NetBIOS address của DC để query global group | Lấy bằng netdom query dc |
⚠️ Giới hạn quan trọng về nested group: nested group chỉ hỗ trợ khi excluded group là Local Group. Với Global/Domain group, user phải là thành viên trực tiếp — thành viên gián tiếp qua group con sẽ không được miễn.
Và với Global group, bắt buộc phải đặt excluded_group_netbios_address, nếu không tính năng im lặng không hoạt động.
6.4.3. ⭐ Filter và scenario (cpus_*) — phần nguy hiểm nhất
🔴 Đọc kỹ bảng này trước khi đặt bất kỳ giá trị nào. Đặt sai là tự khoá mình khỏi máy.
| Value | Kiểu | Ý nghĩa |
|---|---|---|
enable_filter |
REG_SZ | 1 = bật Filter. Mặc định Filter chặn hết CP khác |
filter_whitelist |
REG_MULTI_SZ | CLSID của các CP được miễn lọc |
cpus_logon |
REG_SZ | Phạm vi áp dụng khi đăng nhập |
cpus_unlock |
REG_SZ | Phạm vi khi mở khoá màn hình |
cpus_credui |
REG_SZ | Phạm vi cho hộp thoại nhập credential (UAC, Run as) |
Định dạng giá trị: một chữ số 0-3 + một ký tự e hoặc d.
| Chữ số | Ý nghĩa |
|---|---|
0 |
Áp dụng cho CẢ remote (RDP) VÀ local (console) ← không phải "tắt"! |
1 |
Chỉ remote (RDP) ⭐ giá trị nên dùng khi bắt đầu |
2 |
Chỉ local (console) |
3 |
Tắt hoàn toàn ← đây mới là giá trị để tắt |
| Ký tự | Ý nghĩa |
|---|---|
e |
Exclusive — chỉ hiện privacyIDEA CP, chặn mọi CP khác |
d |
Default — hiện privacyIDEA CP nhưng vẫn cho chọn CP khác ⭐ an toàn hơn khi thử nghiệm |
Ví dụ:
| Giá trị | Kết quả |
|---|---|
cpus_logon = 1d |
⭐ Chỉ RDP bị bắt OTP, vẫn giữ CP khác làm đường lui — bắt đầu bằng cái này |
cpus_logon = 0d |
Cả RDP lẫn console bị bắt OTP, vẫn có đường lui |
cpus_logon = 0e |
Cả RDP lẫn console, chỉ privacyIDEA CP — chỉ dùng khi đã chạy ổn định |
cpus_unlock = 3d |
Không bắt OTP khi mở khoá màn hình |
cpus_credui = 3e |
🔴 KHÔNG HỢP LỆ — 3 tắt privacyIDEA CP, e chặn mọi CP khác → không còn CP nào, không đăng nhập được |
🔴 3e là tổ hợp chết người. Nếu lỡ đặt, bạn phải vào Safe Mode để sửa registry.
Hai ràng buộc nữa:
- Các setting
cpus_*chỉ có hiệu lực khienable_filter = 1(từ v3.2). enable_filterlà BẮT BUỘC cho RDP. Nếu không bật, Windows sẽ tự dùng System Credential Provider và bỏ qua privacyIDEA hoàn toàn — bạn sẽ đăng nhập được mà không bị hỏi OTP, tưởng là đã cấu hình sai ở đâu đó.
⚠️ Lưu ý Windows 10/11: CPUS_UNLOCK không còn được kích hoạt khi mở khoá workstation — Windows coi đó là CPUS_LOGON. Muốn khôi phục hành vi cũ phải tắt Fast User Switching qua Group Policy.
💡 Khuyến nghị chính thức cho RDP: chỉ cài privacyIDEA CP trên máy đích RDP, không cài trên máy nguồn.
6.4.4. Trải nghiệm đăng nhập
Từ v3.7.0, thứ tự mặc định là: username → MFA → password.
| Value | Ý nghĩa |
|---|---|
username_password |
1 = hỏi username + password ngay bước 1 |
two_step_send_password |
1 = gửi mật khẩu của user tới privacyIDEA (để trigger token). ⭐ Cần khi dùng policy otppin=userstore. Bật cái này tự động chuyển sang hỏi username+password ở bước 1 |
two_step_send_empty_password |
1 = gửi mật khẩu rỗng (dùng để trigger token PUSH/WebAuthn) |
send_upn |
1 = gửi UPN thay vì DOMAIN\user |
resolve_upn |
1 = resolve UPN → DOMAIN\user. ⚠️ Gây chậm nếu DC không tới được |
hide_domainname |
1 = ẩn tên domain ở màn hình khoá |
hide_fullname |
1 = ẩn cả user lẫn domain |
no_default |
1 = CP không được chọn mặc định |
prefill_username |
1 = điền sẵn user đăng nhập lần trước |
enable_reset |
1 = hiện link reset login |
otp_fail_return_to_first_step |
1 = quay lại bước 1 khi sai OTP. Mặc định 0 (hỏi lại OTP) |
v1_bitmap_path |
Logo. Bắt buộc Windows BMP version 3, đúng 128×128 px |
🔴 Thay đổi lớn từ v3.7.0: các giá trị login_text, otp_text, reset_link_text… đã bị thay thế bởi hệ thống file dịch. Chúng nằm tại:
C:\ProgramData\Netknights GmbH\PrivacyIDEA Credential Provider\locales
Phải "Take Ownership" hoặc đổi permission mới sửa được. Ánh xạ: login_text → ID 17, otp_text → ID 6, otp_fail_text → ID 7, reset_link_text → ID 8.
Nếu bạn đặt login_text trong registry và không thấy gì thay đổi — đây là lý do.
6.4.5. Realm mapping
| Value | Ý nghĩa |
|---|---|
default_realm |
Realm mặc định cho mọi request |
Ánh xạ chi tiết nằm ở sub-key ...\PrivacyIDEA-CP\realm-mapping, mỗi entry là REG_SZ với name = Windows domain, data = privacyIDEA realm.
Ví dụ: name LAB → data lab.
6.4.6. Offline OTP
Cho phép đăng nhập khi mất kết nối tới OTP server. Hỗ trợ token HOTP và FIDO.
| Value | Mặc định | Ý nghĩa |
|---|---|---|
offline_file |
C:\offlineFile.json |
Đường dẫn tuyệt đối |
offline_try_window |
10 |
Số OTP offline đem so khớp |
offline_threshold |
— | Chỉ refill khi số OTP còn lại xuống dưới ngưỡng này |
offline_show_info |
0 |
1 = hiện thông tin token offline |
offline_expiration_days |
0 |
Số ngày token offline còn hiệu lực sau lần refill online. 0 = không hết hạn |
offline_delete_after_days |
0 |
Số ngày sau khi hết hạn thì xoá hẳn |
check_all_offline_credentials |
0 |
1 = kiểm mọi credential offline (máy dùng chung). ⚠️ Nhiều credential sẽ làm login rất chậm |
Bật offline cho một token: WebUI → chọn token → xuống cuối trang → Application ⇒ Offline.
⚠️ Khuyến cáo chính thức: "Dùng HOTP offline không còn được khuyến nghị" — vì token HOTP sau khi bật offline sẽ không dùng được cho xác thực online hoặc trên máy khác. Token FIDO không bị hạn chế này: dùng được cả online lẫn offline, và dữ liệu offline có thể nằm trên nhiều máy.
Bẫy hiệu năng: nếu máy thực sự offline mà CP vẫn thử refill → timeout → login chậm. Đặt offline_threshold để chỉ refill khi cần, thay vì refill sau mỗi lần.
6.4.7. Script cấu hình mẫu — giai đoạn đầu (RDP-only)
PS> $K = "HKLM:\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP"
PS> New-Item -Path $K -Force | Out-Null
# ── Kết nối ────────────────────────────────────────────────────
PS> Set-ItemProperty -Path $K -Name "hostname" -Type String -Value "otp01.lab.local"
PS> Set-ItemProperty -Path $K -Name "path" -Type String -Value "/"
PS> Set-ItemProperty -Path $K -Name "fallback_hostname" -Type String -Value "otp02.lab.local"
PS> Set-ItemProperty -Path $K -Name "connect_timeout" -Type String -Value "10000"
PS> Set-ItemProperty -Path $K -Name "receive_timeout" -Type String -Value "10000"
# ── Filter + phạm vi: CHỈ RDP, vẫn giữ CP khác làm đường lui ────
PS> Set-ItemProperty -Path $K -Name "enable_filter" -Type String -Value "1"
PS> Set-ItemProperty -Path $K -Name "cpus_logon" -Type String -Value "1d"
PS> Set-ItemProperty -Path $K -Name "cpus_unlock" -Type String -Value "3d"
PS> Set-ItemProperty -Path $K -Name "cpus_credui" -Type String -Value "3d"
# ── Break-glass ────────────────────────────────────────────────
PS> Set-ItemProperty -Path $K -Name "excluded_account" -Type String -Value "WINSRV02\brk-glass"
# ── Luồng đăng nhập: gửi mật khẩu AD để dùng otppin=userstore ───
PS> Set-ItemProperty -Path $K -Name "two_step_send_password" -Type String -Value "1"
PS> Set-ItemProperty -Path $K -Name "default_realm" -Type String -Value "lab"
# ── Bật log để debug giai đoạn đầu ─────────────────────────────
PS> Set-ItemProperty -Path $K -Name "debug_log" -Type String -Value "1"
# ── Xem lại toàn bộ ────────────────────────────────────────────
PS> Get-ItemProperty -Path $K | Format-List
Ánh xạ realm (nếu tên domain khác tên realm):
PS> New-Item -Path "$K\realm-mapping" -Force | Out-Null
PS> Set-ItemProperty -Path "$K\realm-mapping" -Name "LAB" -Type String -Value "lab"
6.5. Triển khai hàng loạt qua GPO
Cách 1 — truyền property qua command line:
msiexec /i privacyIDEACredentialProvider.msi /qn ^
HOSTNAME=otp01.lab.local ^
PATH=/ ^
FALLBACK_HOSTNAME=otp02.lab.local ^
ENABLE_FILTER=1 ^
CPUS_LOGON=1d ^
CPUS_UNLOCK=3d ^
CPUS_CREDUI=3d ^
TWO_STEP_SEND_PASSWORD=1 ^
DEFAULT_REALM=lab ^
EXCLUDED_ACCOUNT=LAB\brk-glass ^
DEBUG_LOG=1
Quy tắc đặt tên property: tên registry value viết HOA toàn bộ. Ví dụ two_step_send_password → TWO_STEP_SEND_PASSWORD.
⚠️ Một ngoại lệ của quy tắc: registry offline_file → MSI property là OFFLINE_FILE_PATH (không phải OFFLINE_FILE).
Registry value KHÔNG có MSI property tương ứng — phải đặt sau khi cài, bằng GPO Registry Preference hoặc script:
log_sensitiveuser_agent_hide_computer_namerealm-mapping(là sub-key, không phải value)
Cách 2 — MSI Transform bằng Orca:
- Mở MSI trong Orca →
Transform → New Transform - Sửa bảng
Property, thêm các property ở trên Transform → Generate Transform→ lưuprivacyidea-cp.mst- GPMC → GPO mới →
Computer Configuration → Policies → Software Settings → Software installation→ New Package → chọn MSI → Advanced → tab Modifications → Add.mst
Cách 3 — GPO Registry Preference (linh hoạt nhất, đổi cấu hình không cần cài lại):
Computer Configuration → Preferences → Windows Settings → Registry → tạo các item trỏ vào HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP.
💡 Rollout theo đợt: tạo group AD MFA-Wave1, MFA-Wave2… rồi dùng Security Filtering của GPO theo group. Đây là cách kiểm soát rủi ro tốt nhất — không bao giờ bật cho cả tổ chức cùng lúc.
6.6. Test
6.6.1. Test RDP
- Từ WINCLI01:
mstsc /v:192.168.10.22 - Màn hình đăng nhập phải hiện tile của privacyIDEA
- Nhập
LAB\hoangnv+ mật khẩu AD - Bước 2: nhập mã OTP 6 số
- Đăng nhập thành công
Kiểm tra phía server:
# Trên OTP01 — xem audit
curl -s -k -G $PI_URL/audit/ -H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "page_size=5" \
| jq '.result.value.auditdata[] | {date, action, success, user, client, serial}'
# Lưu ý: cột IP nguồn trong audit tên là "client", KHÔNG phải "client_ip"
Kiểm tra phía client:
PS> Get-Content C:\PICredentialProviderLog.txt -Tail 50
PS> Get-WinEvent -FilterHashtable @{LogName='Security'; ID=4624} -MaxEvents 3 | Format-List TimeCreated, Message
6.6.2. Test tài khoản break-glass
Đăng nhập bằng WINSRV02\brk-glass → phải không bị hỏi OTP.
🔴 Nếu vẫn bị hỏi OTP, giá trị excluded_account sai. Sửa ngay — đừng bước sang mục tiếp theo. Kiểm tra đúng định dạng MACHINENAME\username hoặc DOMAIN\username.
6.6.3. Test console logon
Chỉ làm sau khi 6.6.1 và 6.6.2 đều pass, và bạn đã có quyền truy cập console qua hypervisor.
# Chuyển từ "1d" (chỉ RDP) sang "0d" (cả console, vẫn giữ đường lui)
PS> Set-ItemProperty -Path "HKLM:\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP" `
-Name "cpus_logon" -Type String -Value "0d"
PS> Restart-Computer
💡 Dùng hậu tố d chứ không phải e ở lần đầu. Nếu tile privacyIDEA lỗi, bạn vẫn chọn được Credential Provider mặc định của Windows để vào máy. Chỉ chuyển sang 0e sau khi đã chạy ổn định vài tuần.
6.6.4. Khôi phục khi bị khoá
Nếu không đăng nhập được:
1. Thử tài khoản break-glass (đã exclude)
2. Nếu vẫn không được → Safe Mode:
- Giữ Shift khi bấm Restart, hoặc từ hypervisor console bấm F8
- Safe Mode vô hiệu hoá Credential Provider của bên thứ ba
- Đăng nhập bằng local admin
3. Trong Safe Mode, tắt CP:
reg add "HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP" /v cpus_logon /t REG_SZ /d "3d" /f
hoặc gỡ hẳn: Control Panel → Programs → Uninstall
4. Reboot
Vô hiệu hoá CP từ ngoài (mount ổ đĩa offline):
Thêm value "Disabled" (REG_DWORD) = 1 vào:
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{7BAF541E-F8E0-4EDF-B69A-BD2771139E8E}
6.7. Tóm tắt Phần 6
Các bước chính:
- Trước khi cài: tạo và test tài khoản break-glass, đảm bảo có console qua hypervisor, push CA nội bộ vào Trusted Root.
- Cài MSI 3.8.0 trên máy đích RDP.
- Registry tại
HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP(có dấu cách và chữGmbH). enable_filter = 1— bắt buộc cho RDP.- Bắt đầu bằng
cpus_logon = "1d"(chỉ RDP, giữ đường lui). Nhớ:0= cả hai,3= tắt,3elà tổ hợp chết. excluded_accountđúng định dạngDOMAIN\username, và test nó.two_step_send_password = 1nếu dùngotppin=userstore.- Chỉ chuyển sang
0d(console) sau khi RDP đã chạy ổn.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
| Không thấy tile privacyIDEA | CLSID chưa đăng ký, hoặc cpus_logon bắt đầu bằng 3 |
Kiểm tra registry CLSID; đặt cpus_logon = "1d" |
| RDP không bị hỏi OTP | enable_filter chưa bật → Windows dùng System CP |
enable_filter = "1" — đây là lỗi phổ biến nhất |
| Không CP nào hiện, không login được | Đặt cpus_* = "3e" |
Safe Mode → sửa registry |
| Cấu hình registry không có tác dụng | Sai đường dẫn (thiếu GmbH) |
Dùng đúng HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP |
cpus_unlock không có tác dụng trên Win10/11 |
Windows coi unlock là CPUS_LOGON |
Tắt Fast User Switching qua GPO |
| Lỗi SSL / không kết nối | CA chưa tin cậy hoặc CN không khớp | Push CA qua GPO. Tạm dùng ssl_ignore_unknown_ca=1 chỉ để xác nhận nguyên nhân |
| Login rất chậm | resolve_upn=1 mà DC không tới được; hoặc refill offline khi mất mạng; hoặc check_all_offline_credentials=1 |
Tắt resolve_upn; đặt offline_threshold |
Sửa login_text không có tác dụng |
Từ v3.7.0 đã thay bằng file locales | Sửa file trong C:\ProgramData\Netknights GmbH\PrivacyIDEA Credential Provider\locales (cần Take Ownership) |
excluded_group không hoạt động |
Global group thiếu excluded_group_netbios_address; hoặc nested global group (không hỗ trợ) |
Đặt NetBIOS address; hoặc dùng Local Group |
excluded_account không hoạt động |
Sai định dạng | Phải là domain\username hoặc computername\username |
| Token offline hết hạn bất ngờ | offline_expiration_days > 0 mà không refill online kịp |
Đặt 0, hoặc tăng giá trị |
| Không biết lỗi gì | Chưa bật log | debug_log = "1" → đọc C:\PICredentialProviderLog.txt. (Kể cả khi tắt, lỗi thật vẫn được ghi vào file này) |
PHẦN 7. CLIENT LINUX — SSH
Mục tiêu: bắt buộc nhập OTP khi SSH vào LNX01 (Ubuntu 24.04).
privacyIDEA cho Linux có hai phương án, và phương án thứ hai là điểm mạnh mà multiOTP không có:
Phương án A: pam_radius |
Phương án B: PAM module native | |
|---|---|---|
| Giao thức | RADIUS 1812/UDP | HTTPS REST API |
| Cài đặt | apt install libpam-radius-auth |
Cài privacyidea-pam hoặc từ GitHub |
| Offline OTP | ❌ Không | ⭐ Có — cache trước N mã HOTP xuống máy |
| Challenge-response | 🟡 Hạn chế | ✅ Đầy đủ |
| Phù hợp | Thiết bị mạng, môi trường đã chuẩn hoá RADIUS | ⭐ Server/laptop Linux, chi nhánh mạng chập chờn |
Tài liệu này làm cả hai: A trước (phổ biến hơn), B sau (cho máy cần offline).
7.1. 🔴 ĐỌC TRƯỚC: quy tắc an toàn khi sửa PAM
Sửa sai PAM là cách nhanh nhất để khoá chính mình khỏi server.
Quy tắc "hai terminal" — bắt buộc:
- Mở Terminal A: SSH vào LNX01, chạy
sudo -i, và KHÔNG ĐÓNG NÓ cho tới khi mọi thứ đã verify xong. Session đang mở không bị ảnh hưởng bởi thay đổi PAM. - Mọi thao tác sửa file thực hiện ở Terminal A.
- Mở Terminal B để test đăng nhập mới.
- Terminal B không vào được → quay lại Terminal A rollback.
Ngoài ra: đảm bảo có console qua hypervisor/IPMI làm lớp bảo hiểm cuối.
Backup trước khi sửa bất cứ thứ gì:
# Terminal A
BAK=/root/pam-backup-$(date +%F-%H%M)
sudo mkdir -p $BAK
sudo cp -a /etc/pam.d/sshd $BAK/
sudo cp -a /etc/pam.d/common-auth $BAK/
sudo cp -a /etc/ssh/sshd_config $BAK/
sudo cp -a /etc/ssh/sshd_config.d/ $BAK/ 2>/dev/null
sudo ls -la $BAK
echo "Backup tai: $BAK"
7.2. Chuẩn bị LNX01
# Đồng bộ thời gian
sudo apt install -y chrony
sudo timedatectl set-timezone Asia/Bangkok
echo "server 192.168.10.5 iburst prefer" | sudo tee -a /etc/chrony/chrony.conf
sudo systemctl restart chrony
timedatectl status | grep synchronized # phải là: yes
# Công cụ test RADIUS
sudo apt install -y freeradius-utils
# ⭐ Test RADIUS TRƯỚC khi động vào PAM
radtest hoangnv 'MatKhauAD123456' 192.168.10.10:1812 0 'SECRET_CUA_LNX01'
🛑 Nếu lệnh trên không ra Access-Accept, DỪNG LẠI. Debug ở tầng RADIUS (Phần 5.8) rẻ hơn nhiều so với debug qua PAM.
💡 Điểm hay bị hiểu nhầm: giờ trên LNX01 không ảnh hưởng tới TOTP — server OTP mới là bên tính giờ. Nhưng vẫn nên đồng bộ vì nó ảnh hưởng log, chứng chỉ TLS, và khả năng tương quan sự kiện khi điều tra sự cố.
7.3. Phương án A — PAM + RADIUS
7.3.1. Cài đặt
sudo apt install -y libpam-radius-auth
# Xác nhận đường dẫn thật
dpkg -L libpam-radius-auth | grep -E '\.so$|conf'
ls -l /usr/lib/x86_64-linux-gnu/security/pam_radius_auth.so
7.3.2. Cấu hình pam_radius_auth.conf
sudo nano /etc/pam_radius_auth.conf
Xoá hết dòng mẫu (127.0.0.1 secret 1) rồi đặt:
# server[:port] shared_secret timeout [src_ip]
192.168.10.10:1812 SECRET_CUA_LNX01 10
192.168.10.11:1812 SECRET_CUA_LNX01_TREN_OTP02 10
| Cột | Ý nghĩa |
|---|---|
server[:port] |
IP/hostname RADIUS server. Không ghi port thì mặc định 1812 |
shared_secret |
Phải khớp chính xác với clients.conf trên OTP01 |
timeout |
Giây. Đặt 10 — ngắn quá thì fail khi mạng chậm, dài quá thì user chờ lâu khi server chết |
src_ip |
(tuỳ chọn) IP nguồn để bind, cần khi máy nhiều NIC |
Dòng thứ hai chính là failover — pam_radius tự chuyển sang OTP02 sau khi OTP01 timeout.
Siết quyền — file chứa shared secret:
sudo chown root:root /etc/pam_radius_auth.conf
sudo chmod 600 /etc/pam_radius_auth.conf
ls -l /etc/pam_radius_auth.conf # -rw------- root root
7.3.3. Cấu hình OpenSSH
sudo nano /etc/ssh/sshd_config
# ══ Bắt buộc cho OTP qua PAM ═══════════════════════════════════
UsePAM yes
KbdInteractiveAuthentication yes
# Ghi chú: OpenSSH < 8.7 dùng tên cũ "ChallengeResponseAuthentication".
# Ubuntu 24.04 có OpenSSH 9.6 -> dùng tên mới.
# ══ Chọn MỘT trong hai mô hình ═════════════════════════════════
# Mô hình 1: mật khẩu Unix + OTP
PasswordAuthentication no
AuthenticationMethods keyboard-interactive
# Mô hình 2: SSH key + OTP ⭐ khuyến nghị cho production
# PubkeyAuthentication yes
# PasswordAuthentication no
# AuthenticationMethods publickey,keyboard-interactive
# ══ An toàn ════════════════════════════════════════════════════
PermitRootLogin prohibit-password
MaxAuthTries 3
LoginGraceTime 60
⭐ Mô hình 2 là lựa chọn tốt nhất cho production. SSH key (thứ bạn có — file) + OTP (thứ bạn có — điện thoại) + PIN/mật khẩu của OTP (thứ bạn biết). Và không bao giờ có mật khẩu Unix bay qua mạng.
⚠️ Kiểm tra thư mục /etc/ssh/sshd_config.d/. Ubuntu 24.04 để file cấu hình phụ ở đó và chúng ghi đè file chính (dòng Include nằm ở đầu sshd_config). Nhiều người sửa file chính rồi không hiểu vì sao không có tác dụng.
ls -la /etc/ssh/sshd_config.d/
sudo sshd -T | grep -iE 'usepam|kbdinteractive|authenticationmethods|passwordauth'
Lệnh sshd -T in ra cấu hình thực tế đang chạy sau khi đã gộp tất cả — đây mới là sự thật.
Kiểm tra cú pháp trước khi restart:
sudo sshd -t # không in gì = OK
Chưa restart vội — sửa PAM trước.
7.3.4. 🔴 Cấu hình /etc/pam.d/sshd — phần dễ sai nhất
🔴 THỨ TỰ MODULE Ở ĐÂY KHÔNG TUỲ Ý. Đây là lỗi khiến rất nhiều người mất SSH.
Sau khi nhận chuỗi người dùng nhập, pam_radius_auth.so gọi pam_set_item(PAM_AUTHTOK, ...) — nó ghi chuỗi OTP vào ô "mật khẩu dùng chung" của PAM. Module pam_unix phía sau thấy PAM_AUTHTOK đã có sẵn thì dùng luôn chuỗi đó làm mật khẩu Unix và không bao giờ hiện prompt "Password:".
Hệ quả nếu đặt pam_radius_auth.so TRƯỚC @include common-auth: người dùng chỉ được hỏi OTP, rồi pam_unix lấy chính chuỗi OTP đó đi so với mật khẩu Unix → luôn sai → không ai đăng nhập được.
Cách đúng: @include common-auth TRƯỚC, pam_radius_auth.so SAU, kèm option force_prompt.
Mô hình 1 — mật khẩu Unix + OTP:
sudo nano /etc/pam.d/sshd
#%PAM-1.0
# ══ YẾU TỐ 1: mật khẩu Unix/AD — PHẢI ĐẶT TRƯỚC ════════════════
@include common-auth
# ══ YẾU TỐ 2: OTP qua RADIUS ═══════════════════════════════════
# force_prompt : ⭐ BẮT BUỘC. Buộc hỏi lại thay vì tái dùng PAM_AUTHTOK
# retry=1 : chỉ cho nhập lại 1 lần
# prompt=OTP : nhãn hiển thị "OTP: " thay vì "Password: "
# conf=<path> : chỉ định file config nếu không dùng đường dẫn mặc định
auth required pam_radius_auth.so force_prompt retry=1 prompt=OTP
# ── Phần còn lại giữ nguyên như bản gốc của Ubuntu ──
account required pam_nologin.so
@include common-account
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close
session required pam_loginuid.so
session optional pam_keyinit.so force revoke
@include common-session
session optional pam_motd.so motd=/run/motd.dynamic
session optional pam_motd.so noupdate
session optional pam_mail.so standard noenv
session required pam_limits.so
session required pam_env.so
session required pam_env.so user_readenv=1 envfile=/etc/default/locale
session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open
@include common-password
Mô hình 2 — SSH key + OTP (file gọn hơn nhiều):
#%PAM-1.0
# Chỉ có OTP ở tầng auth; SSH key đã do sshd xử lý trước đó.
# Không có pam_unix phía sau nên không dính bẫy PAM_AUTHTOK,
# nhưng vẫn để force_prompt cho nhất quán.
auth required pam_radius_auth.so force_prompt retry=1 prompt=OTP
# KHÔNG include common-auth (không hỏi mật khẩu Unix)
account required pam_nologin.so
@include common-account
session required pam_loginuid.so
@include common-session
session optional pam_motd.so motd=/run/motd.dynamic
session required pam_limits.so
session required pam_env.so
@include common-password
Giải nghĩa control flag của PAM — phải hiểu để không tự hại mình:
| Flag | Hành vi |
|---|---|
required |
Phải thành công. Fail vẫn chạy tiếp các module sau rồi mới trả lỗi (không tiết lộ module nào fail) |
requisite |
Phải thành công. Fail là dừng ngay |
sufficient |
Thành công và chưa có required nào fail trước đó → chấp nhận luôn, bỏ qua phần còn lại. 🔴 Cực kỳ nguy hiểm nếu đặt sai chỗ |
optional |
Kết quả không ảnh hưởng (trừ khi là module duy nhất) |
[success=1 default=ignore] |
Cú pháp chi tiết. Số 1 = nhảy qua 1 module tiếp theo |
🔴 Bẫy 1: auth sufficient pam_radius_auth.so khiến user chỉ cần OTP đúng là vào được — mật khẩu Unix bị bỏ qua hoàn toàn. Đó là giảm bảo mật, không phải tăng. Luôn dùng required cho cả hai yếu tố.
🔴 Bẫy 2: trong common-auth của Ubuntu có dòng auth [success=1 default=ignore] pam_unix.so nullok. Con số success=1 là nhảy qua 1 module tiếp theo. Nếu bạn chèn module vào giữa khối common-auth, logic nhảy bị lệch và kết quả rất khó đoán. Luôn chèn ở TRƯỚC hoặc SAU cả khối @include common-auth, đừng bao giờ chèn vào giữa.
🔴 Bẫy 3: đừng sửa /etc/pam.d/common-auth. File đó ảnh hưởng toàn bộ dịch vụ xác thực trên máy — kể cả sudo, login, su, và cả màn hình đăng nhập đồ hoạ. Chỉ sửa /etc/pam.d/sshd.
7.3.5. Áp dụng và test
# Terminal A (session cũ — GIỮ NGUYÊN)
sudo sshd -t # kiểm tra cú pháp lần cuối
sudo systemctl restart ssh # Ubuntu 24.04: service tên là "ssh"
sudo systemctl status ssh
# Theo dõi log real-time ở Terminal A
sudo journalctl -u ssh -f
# Terminal B (session MỚI) — test
ssh -v [email protected]
Kết quả mong đợi với Mô hình 1:
Password: ← mật khẩu Unix/AD trước (common-auth)
OTP: 4829173940 ← rồi tới PIN/mật khẩu + OTP
Welcome to Ubuntu 24.04 LTS ...
hoangnv@LNX01:~$
Với Mô hình 2:
debug1: Offering public key: /home/user/.ssh/id_ed25519
debug1: Authentications that can continue: keyboard-interactive
OTP: 4829173940
hoangnv@LNX01:~$
Bắt gói để nhìn tận mắt:
sudo tcpdump -i any -n -vv 'udp port 1812'
Phải thấy một cặp Access-Request → Access-Accept. Chỉ thấy Access-Request mà không có phản hồi → firewall/routing. Không thấy gì → module PAM chưa được gọi.
7.3.6. Rollback khẩn cấp
# Terminal A
sudo cp $BAK/sshd /etc/pam.d/sshd
sudo cp $BAK/sshd_config /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl restart ssh
Nếu đã mất cả Terminal A: vào console qua hypervisor → đăng nhập local → rollback như trên.
Nếu console cũng bị chặn (do lỡ sửa common-auth), boot vào rescue mode:
GRUB → nhấn 'e' ở dòng kernel → thêm 'init=/bin/bash' vào cuối dòng linux → Ctrl+X
# mount -o remount,rw /
# cp /root/pam-backup-.../sshd /etc/pam.d/sshd
# cp /root/pam-backup-.../common-auth /etc/pam.d/common-auth
# sync; reboot -f
7.4. Phương án B — PAM module native (có Offline OTP)
Đây là điểm mạnh riêng của privacyIDEA. Module gọi thẳng REST API và có thể cache trước N mã HOTP xuống máy, cho phép đăng nhập ngay cả khi mất hoàn toàn kết nối tới OTP server.
Phù hợp cho: laptop, máy ở chi nhánh, server ở site có đường truyền không ổn định.
7.4.1. Cài đặt
# Cách 1: nếu repo NetKnights có gói cho noble
apt-cache policy privacyidea-pam
sudo apt install -y privacyidea-pam
# Cách 2: cài thủ công từ GitHub
sudo apt install -y libpam-python python3-requests git
git clone https://github.com/privacyidea/pam_python.git /tmp/pi-pam
sudo cp /tmp/pi-pam/privacyidea_pam.py /lib/security/
sudo chmod 644 /lib/security/privacyidea_pam.py
7.4.2. Cấu hình
sudo nano /etc/pam.d/sshd
#%PAM-1.0
# ══ YẾU TỐ 1 ═══════════════════════════════════════════════════
@include common-auth
# ══ YẾU TỐ 2: OTP qua REST API, có offline ═════════════════════
auth requisite pam_python.so /lib/security/privacyidea_pam.py \
url=https://otp01.lab.local \
realm=lab \
prompt=[Ma OTP] \
sqlfile=/etc/privacyidea/pam.sqlite
account required pam_nologin.so
@include common-account
session required pam_loginuid.so
@include common-session
@include common-password
| Tuỳ chọn | Ý nghĩa |
|---|---|
url= |
Endpoint privacyIDEA |
realm= |
Realm gửi kèm |
prompt= |
Nhãn hiển thị. ⚠️ PAM không hiểu dấu nháy kép — giá trị có khoảng trắng phải bọc trong [...], ví dụ prompt=[Ma OTP]. Viết prompt="Ma OTP" sẽ cho ra prompt hỏng là "Ma : |
sqlfile= |
⭐ Bật offline OTP. File SQLite cache các mã HOTP đã tải trước |
nosslverify |
Bỏ qua verify TLS — ⚠️ chỉ khi test |
try_first_pass |
Dùng lại mật khẩu đã nhập ở module trước |
debug |
Log chi tiết vào syslog |
sudo install -d -o root -g root -m 700 /etc/privacyidea
sudo touch /etc/privacyidea/pam.sqlite
sudo chmod 600 /etc/privacyidea/pam.sqlite
7.4.3. Bật offline cho token
- WebUI →
Tokens→ chọn token của user - Xuống cuối trang → Application ⇒ Offline
- Đặt số lượng mã cần cache (VD: 100)
- Người dùng đăng nhập online một lần → mã được tải xuống
pam.sqlite - Từ đó về sau, mất mạng vẫn đăng nhập được
⚠️ Đánh đổi của offline HOTP: token HOTP sau khi bật offline sẽ không dùng được cho xác thực online hoặc trên máy khác. Nếu người dùng cần đăng nhập nhiều máy, hãy cấp cho họ token thứ hai (nhớ policy max_active_token_per_user=2 ở mục 4.3), hoặc dùng token FIDO — loại này không bị hạn chế.
7.5. Miễn trừ OTP cho automation
Script backup, Ansible, monitoring agent không thể nhập OTP tương tác. Cách xử lý đúng:
sudo nano /etc/ssh/sshd_config
# Tài khoản automation: CHỈ SSH key, không qua PAM OTP
Match User ansible-svc,backup-svc,monitoring
AuthenticationMethods publickey
PubkeyAuthentication yes
KbdInteractiveAuthentication no
PasswordAuthentication no
# Người dùng thật từ subnet quản trị: bắt OTP đầy đủ
Match Address 192.168.10.0/24
AuthenticationMethods publickey,keyboard-interactive
💡 Bù lại phần bảo mật đã bỏ cho tài khoản automation bằng cách siết authorized_keys:
from="192.168.10.99",command="/usr/local/bin/backup.sh",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...
| Tuỳ chọn | Tác dụng |
|---|---|
from="IP" |
Key chỉ dùng được từ IP đó |
command="..." |
Key chỉ chạy được đúng lệnh đó, không mở shell |
no-pty |
Không cấp terminal |
no-port-forwarding |
Không tunnel được |
Một key bị lộ mà có đủ 4 ràng buộc này thì gần như vô hại.
7.6. Test đầy đủ
# 1. RADIUS thông
radtest hoangnv 'MatKhauAD123456' 192.168.10.10:1812 0 'SECRET'
# -> Access-Accept
# 2. Failover: dừng FreeRADIUS trên OTP01, test lại -> phải rơi sang OTP02
# (trên OTP01: sudo systemctl stop freeradius)
time ssh [email protected]
# -> vào được, chậm hơn ~10 giây (thời gian timeout server 1)
# 3. SSH mã đúng -> vào được
ssh [email protected]
# 4. SSH mã SAI -> bị từ chối
ssh [email protected] # nhập 000000 -> Permission denied
# 5. Replay: đăng nhập xong, thử lại NGAY với cùng mã -> phải bị từ chối
# 6. Automation vào bằng key, không bị hỏi OTP
ssh -i ~/.ssh/ansible_ed25519 [email protected]
# 7. Console/local login vẫn hoạt động
# (vào console qua hypervisor và đăng nhập)
# 8. sudo vẫn bình thường (ta không đụng /etc/pam.d/sudo)
sudo -l
# 9. Offline (nếu dùng Phương án B): ngắt mạng tới OTP01 rồi thử SSH
sudo iptables -A OUTPUT -d 192.168.10.10 -j DROP
ssh [email protected] # phải vào được bằng mã offline
sudo iptables -D OUTPUT -d 192.168.10.10 -j DROP
7.7. Tóm tắt Phần 7
Các bước chính:
- Giữ Terminal A mở, backup
/etc/pam.d/sshd+sshd_config+common-auth. chronychạy,radtestraAccess-Accepttrước khi động vào PAM.- Cài
libpam-radius-auth, khai server chính + backup trong/etc/pam_radius_auth.conf,chmod 600. sshd_config:UsePAM yes,KbdInteractiveAuthentication yes,AuthenticationMethods publickey,keyboard-interactive. Kiểm tra/etc/ssh/sshd_config.d/.- 🔴
/etc/pam.d/sshd:@include common-authTRƯỚC, rồiauth required pam_radius_auth.so force_prompt retry=1 prompt=OTP. sshd -t→systemctl restart ssh→ test ở Terminal B.- Máy cần offline: dùng Phương án B với
sqlfile=. - Automation:
Match User+AuthenticationMethods publickey+ siếtauthorized_keys.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
| Chỉ bị hỏi OTP, không hỏi mật khẩu, và luôn fail | 🔴 pam_radius_auth.so đặt trước @include common-auth → ghi OTP vào PAM_AUTHTOK, pam_unix dùng lại chuỗi đó |
Đảo thứ tự + thêm force_prompt |
| SSH treo 10–30 giây rồi Permission denied | RADIUS không phản hồi → chờ hết timeout | tcpdump -n udp port 1812; kiểm tra firewall và clients.conf |
Cannot open configuration file |
Sai đường dẫn config theo distro | dpkg -L libpam-radius-auth; hoặc thêm conf=/duong/dan vào dòng PAM |
Access-Reject dù mã đúng |
Shared secret lệch — thường do dấu cách thừa cuối dòng | cat -A /etc/pam_radius_auth.conf để thấy ký tự ẩn |
| Không hề bị hỏi OTP | UsePAM no, KbdInteractiveAuthentication no, hoặc file trong sshd_config.d/ ghi đè |
sshd -T \| grep -i pam — xem cấu hình thực tế |
| Bị hỏi "Password:" hai lần | Cả hai module dùng nhãn mặc định | Thêm prompt=OTP |
| Mất SSH hoàn toàn | Sai cú pháp hoặc thứ tự module | Console qua hypervisor → rollback. Console cũng chặn → GRUB init=/bin/bash |
sudo cũng đòi OTP |
Lỡ sửa /etc/pam.d/common-auth |
Chỉ sửa file sshd. Rollback common-auth từ backup |
| Ansible/script fail | Không nhập được OTP tương tác | Match User + AuthenticationMethods publickey |
| Offline OTP không hoạt động | Chưa bật Application ⇒ Offline cho token, hoặc chưa đăng nhập online lần nào | Bật trong WebUI, đăng nhập online một lần để nạp mã |
| Token offline không dùng được ở máy khác | Đây là hành vi của HOTP offline | Cấp token thứ hai, hoặc dùng token FIDO |
PHẦN 8. TÍCH HỢP VÀO ỨNG DỤNG WEB
8.1. Chọn kiến trúc
| Kiến trúc | Cách hoạt động | Ưu | Nhược | Dùng khi |
|---|---|---|---|---|
| A. Gọi REST API privacyIDEA ⭐ | App POST tới /validate/check |
Dùng chung một token với Windows/Linux; app không lưu seed; revoke một chỗ; audit tập trung | Phụ thuộc OTP server | ⭐ Khuyến nghị — bạn đã có server rồi |
| B. Gọi RADIUS | App gửi Access-Request tới 1812/UDP | Cũng dùng chung token | Giao thức cũ, khó debug hơn HTTPS | Khi app đã có sẵn client RADIUS |
| C. App tự quản lý TOTP (pyotp) | App tự sinh seed, tự lưu, tự verify | Không phụ thuộc gì | Seed rải rác; user phải enroll riêng cho từng app; không có policy/audit chung | App tách biệt hoàn toàn, hoặc SaaS bên ngoài |
| D. SSO qua Keycloak + privacyIDEA | privacyIDEA làm nguồn OTP cho Keycloak, app dùng OIDC | Chuẩn nhất cho nhiều app | Thêm một tầng hạ tầng | Khi có > 5 web app |
Tài liệu này làm Kiến trúc A đầy đủ, và có mục 8.5 cho Kiến trúc C khi bạn cần app độc lập.
💡 Lợi ích lớn nhất của Kiến trúc A không phải kỹ thuật mà là vận hành: người dùng chỉ cầm một token cho Windows, SSH và mọi web app nội bộ. Khi họ mất điện thoại, bạn reset một chỗ. Khi họ nghỉ việc, bạn vô hiệu hoá một chỗ. Đây là điều bạn sẽ biết ơn sau 6 tháng.
8.2. Hiểu API trước khi viết code
Endpoint xác thực:
curl -k -X POST https://otp01.lab.local/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD123456"
Thành công:
{
"detail": {"message": "matching 1 tokens", "serial": "TOTP0001A2B3", "type": "totp"},
"result": {"status": true, "value": true},
"version": "privacyIDEA 3.13.3"
}
Thất bại:
{
"detail": {"message": "wrong otp value"},
"result": {"status": true, "value": false},
"version": "privacyIDEA 3.13.3"
}
🔴 ĐÂY LÀ ĐIỂM QUAN TRỌNG NHẤT CỦA CẢ PHẦN 8.
| Trường | Ý nghĩa |
|---|---|
result.status |
Request có được xử lý xong không. Sai OTP vẫn trả true |
result.value |
⭐ Kết quả xác thực thật sự |
Nhầm hai trường này tạo ra một lỗ hổng xác thực hoàn toàn — mọi mã OTP sai đều được chấp nhận. Nếu bạn chỉ nhớ một điều từ Phần 8, hãy nhớ điều này.
Challenge-response — khi dùng token PUSH, SMS OTP hoặc Email OTP, lần gọi đầu trả về:
{
"detail": {
"message": "Please enter otp from your email",
"transaction_id": "12345678901234567890",
"serial": "PIEM0001",
"multi_challenge": [{"serial": "PIEM0001", "transaction_id": "1234...", "client_mode": "interactive"}]
},
"result": {"status": true, "value": false}
}
App phải hiển thị ô nhập mã, rồi gọi lại kèm transaction_id.
Các endpoint chính:
| Endpoint | Method | Auth | Mục đích |
|---|---|---|---|
/validate/check |
POST | Không | ⭐ Xác thực người dùng |
/validate/radiuscheck |
POST | Không | Xác thực, trả HTTP 204 (OK) / 400 (fail), body rỗng |
/auth |
POST | Không | Lấy JWT cho thao tác quản trị |
/token/init |
POST | JWT | Enroll token mới |
/token/ |
GET | JWT | Liệt kê token |
/user/ |
GET/POST | JWT | Quản lý user trong resolver editable |
/audit/ |
GET | JWT | Đọc audit log |
⚠️ Header xác thực là PI-Authorization: <JWT> — JWT thô, KHÔNG có tiền tố Bearer. Đây là điểm khác với hầu hết API khác và là lỗi rất hay gặp.
8.3. Bảo vệ endpoint /validate/check
Mặc định endpoint này mở cho mọi máy trong mạng. Bất kỳ ai cũng có thể dò user và brute-force. Ba lớp bảo vệ:
Lớp 1 — chặn theo IP nguồn bằng policy:
🔴 Đừng dùng authorized=grant_access để chặn — nó không chặn gì cả.
privacyIDEA chỉ từ chối khi có policy khớp với giá trị deny_access. Với grant_access + client=192.168.10.0/24: client nội bộ khớp → cho qua; client ngoài dải thì không policy nào khớp → cũng cho qua. Bạn được một cảm giác an toàn giả.
Cách đúng — dùng deny_access và gán client là dải cần chặn:
sudo pi-manage config policy create authz_deny_external authorization "authorized=deny_access"
# WebUI -> Config -> Policies -> authz_deny_external -> client = !192.168.10.0/24
# Dấu '!' đứng trước nghĩa là "mọi IP NGOÀI dải này"
Kiểm chứng ngay sau khi tạo — từ một máy ngoài dải:
curl -sk -X POST https://otp01.lab.local/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=<matkhau><OTP>" | jq '.result'
# Phải bị từ chối
💡 Chặn ở tầng firewall (mục 1.4) vẫn là lớp phòng thủ đáng tin cậy nhất. Policy chỉ là lớp bổ sung.
Lớp 2 — API key:
# -u là BẮT BUỘC (tên định danh cho API key này)
sudo pi-manage api createtoken -r validate -u webapp01
Lệnh in ra một API key (hạn 365 ngày, không được lưu trên server — chép ngay). Sau đó tạo policy scope authorization yêu cầu API key. Web app gửi kèm key trong header.
Lớp 3 — rate limiting ở tầng Apache trên OTP01:
⚠️ QS_ClientEventLimitCount CHỈ được đặt ở server context (global), không được đặt trong <VirtualHost>. Đặt sai chỗ thì apache2ctl configtest fail và Apache không reload được.
sudo apt install -y libapache2-mod-qos
# Directive giới hạn -> file conf GLOBAL
sudo tee /etc/apache2/conf-available/qos-validate.conf > /dev/null <<'EOF'
<IfModule mod_qos.c>
# Tối đa 20 request mang biến validate_limit, mỗi IP, mỗi 60 giây
QS_ClientEventLimitCount 20 60 validate_limit
</IfModule>
EOF
sudo a2enconf qos-validate
Chỉ SetEnvIf mới được đặt trong vhost:
sudo nano /etc/apache2/sites-available/privacyidea.conf
# Đánh dấu request cần đếm — dòng này ĐƯỢC phép nằm trong <VirtualHost>
SetEnvIf Request_URI "^/validate/" validate_limit=1
sudo apache2ctl configtest && sudo systemctl reload apache2
8.4. Kiến trúc A — Flask gọi privacyIDEA
8.4.1. Chuẩn bị WEB01
sudo apt update
sudo apt install -y python3 python3-venv python3-pip nginx
sudo useradd -r -s /usr/sbin/nologin webapp
sudo mkdir -p /opt/otpapp && sudo chown webapp:webapp /opt/otpapp
cd /opt/otpapp
sudo -u webapp python3 -m venv venv
sudo -u webapp ./venv/bin/pip install flask requests gunicorn argon2-cffi
⚠️ Ubuntu 24.04 có PEP 668 (EXTERNALLY-MANAGED): pip install vào system Python sẽ bị chặn. Bắt buộc dùng venv. Đừng dùng --break-system-packages để "khắc phục" — nó phá vỡ quản lý gói của hệ thống.
Cài CA nội bộ để verify TLS đúng cách:
sudo cp lab-ca.pem /usr/local/share/ca-certificates/lab-ca.crt
sudo update-ca-certificates
# Hoặc trỏ trực tiếp trong code: verify="/usr/local/share/ca-certificates/lab-ca.crt"
8.4.2. Client library
File pi_client.py đính kèm tài liệu này là một client hoàn chỉnh, đã được test thực tế với các kịch bản: mã đúng, mã sai, challenge-response, radiuscheck 204/400, enroll token, và server chết.
Phần cốt lõi — đọc kết quả cho đúng:
result = j.get("result", {})
detail = j.get("detail") or {}
# ⭐ result.value LÀ kết quả xác thực.
# result.status chỉ nói "request được xử lý xong" — sai OTP vẫn trả status=true.
# Nhầm hai trường này là lỗ hổng xác thực.
return AuthResult(
ok=bool(result.get("value") is True),
message=detail.get("message", ""),
serial=detail.get("serial"),
transaction_id=detail.get("transaction_id"),
raw=j,
)
Ba chi tiết trong client đáng chú ý:
| Chi tiết | Lý do |
|---|---|
Retry(allowed_methods=frozenset(["GET"])) |
⭐ Không retry POST xác thực. Gửi lại cùng một OTP sẽ bị server coi là replay và từ chối — đồng thời tăng failcounter của user một cách oan uổng |
verify mặc định True |
Tắt verify TLS là mở đường cho MITM đọc được cả mật khẩu lẫn OTP |
bool(result.get("value") is True) |
So sánh is True chứ không dùng truthiness — tránh việc một giá trị lạ (chuỗi rỗng, số) bị hiểu nhầm |
8.4.3. Ứng dụng Flask
# /opt/otpapp/app.py
import os
import time
from functools import wraps
from flask import Flask, redirect, render_template_string, request, session, url_for
from pi_client import PrivacyIDEAClient, PrivacyIDEAError
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=True, # yêu cầu HTTPS
PERMANENT_SESSION_LIFETIME=1800,
)
pi = PrivacyIDEAClient(
base_url=os.environ.get("PI_URL", "https://otp01.lab.local"),
realm=os.environ.get("PI_REALM", "lab"),
verify=os.environ.get("PI_CA", "/usr/local/share/ca-certificates/lab-ca.crt"),
timeout=10,
)
LOGIN_HTML = """
<!doctype html><meta charset="utf-8"><title>Dang nhap</title>
<style>body{font-family:system-ui;max-width:380px;margin:80px auto}
input{width:100%;padding:10px;margin:6px 0;box-sizing:border-box}
button{width:100%;padding:10px;cursor:pointer}.err{color:#c00}
small{color:#666}</style>
<h2>LAB Internal Portal</h2>
{% if error %}<p class="err">{{error}}</p>{% endif %}
<form method="post">
<input name="username" placeholder="Tai khoan" autofocus required>
<input name="password" type="password"
placeholder="Mat khau + ma OTP (vd: MatKhau123456)"
autocomplete="current-password" required>
<button>Dang nhap</button>
</form>
<p><small>Nhap mat khau roi noi lien 6 so OTP, khong co dau cach.</small></p>
"""
CHALLENGE_HTML = """
<!doctype html><meta charset="utf-8"><title>Xac thuc</title>
<style>body{font-family:system-ui;max-width:380px;margin:80px auto}
input{width:100%;padding:10px;margin:6px 0;box-sizing:border-box;
letter-spacing:.4em;text-align:center;font-size:20px}
button{width:100%;padding:10px}.err{color:#c00}</style>
<h2>Xac thuc buoc 2</h2>
<p>{{message}}</p>
{% if error %}<p class="err">{{error}}</p>{% endif %}
<form method="post">
<input name="otp" inputmode="numeric" autocomplete="one-time-code" autofocus required>
<button>Xac nhan</button>
</form>
"""
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
return render_template_string(LOGIN_HTML, error=None)
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
try:
res = pi.check(username, password)
except PrivacyIDEAError:
# ⭐ FAIL CLOSED: OTP server chết -> TỪ CHỐI, không cho vào.
# Fail open ở đây là vô hiệu hoá toàn bộ MFA chỉ bằng một
# cuộc tấn công DoS vào OTP server.
app.logger.error("OTP server khong phan hoi cho user=%s", username)
return render_template_string(
LOGIN_HTML,
error="He thong xac thuc tam thoi khong san sang. Vui long thu lai sau."), 503
if res.is_challenge:
session.clear()
session["pending_user"] = username
session["pending_tx"] = res.transaction_id
session["pending_exp"] = int(time.time()) + 180
return redirect(url_for("challenge"))
if not res.ok:
# Thông báo CHUNG CHUNG — không tiết lộ tài khoản nào tồn tại
app.logger.warning("Dang nhap that bai user=%s ip=%s reason=%s",
username, request.remote_addr, res.message)
return render_template_string(
LOGIN_HTML, error="Tai khoan, mat khau hoac ma OTP khong dung"), 401
# ⭐ Thành công: clear session cũ để chống session fixation
session.clear()
session["username"] = username
session["token_serial"] = res.serial
session.permanent = True
app.logger.info("Dang nhap thanh cong user=%s serial=%s", username, res.serial)
return redirect(url_for("dashboard"))
@app.route("/challenge", methods=["GET", "POST"])
def challenge():
if not session.get("pending_tx") or session.get("pending_exp", 0) < time.time():
session.clear()
return redirect(url_for("login"))
if request.method == "GET":
return render_template_string(
CHALLENGE_HTML, message="Nhap ma da duoc gui toi ban.", error=None)
try:
res = pi.check(session["pending_user"], request.form.get("otp", "").strip(),
transaction_id=session["pending_tx"])
except PrivacyIDEAError:
return render_template_string(
CHALLENGE_HTML, message="", error="He thong tam thoi khong san sang"), 503
if not res.ok:
return render_template_string(
CHALLENGE_HTML, message="", error="Ma khong dung"), 401
user = session["pending_user"]
session.clear()
session["username"] = user
session.permanent = True
return redirect(url_for("dashboard"))
def login_required(f):
@wraps(f)
def wrapper(*a, **kw):
if "username" not in session:
return redirect(url_for("login"))
return f(*a, **kw)
return wrapper
@app.route("/")
@login_required
def dashboard():
return (f"<h2>Xin chao {session['username']}</h2>"
f"<p>Token: {session.get('token_serial', '-')}</p>"
f"<a href='/logout'>Dang xuat</a>")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
🔴 Điểm thiết kế quan trọng nhất trong code trên: FAIL CLOSED. Khi OTP server không phản hồi, app từ chối đăng nhập (HTTP 503) chứ không cho qua. Rất nhiều tích hợp MFA mắc lỗi "fail open" cho tiện — và như vậy kẻ tấn công chỉ cần DoS OTP server là vô hiệu hoá toàn bộ MFA của bạn.
Nếu tính sẵn sàng là ưu tiên cao hơn, đừng fail open — hãy dựng node OTP thứ hai (Phần 9.5).
8.4.4. Deploy production
sudo mkdir -p /etc/otpapp
sudo bash -c 'cat > /etc/otpapp/secrets.env' <<EOF
FLASK_SECRET=$(openssl rand -hex 32)
PI_URL=https://otp01.lab.local
PI_REALM=lab
PI_CA=/usr/local/share/ca-certificates/lab-ca.crt
EOF
sudo chmod 600 /etc/otpapp/secrets.env
sudo chown root:root /etc/otpapp/secrets.env
sudo tee /etc/systemd/system/otpapp.service > /dev/null <<'EOF'
[Unit]
Description=OTP Demo Web App
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=webapp
Group=webapp
WorkingDirectory=/opt/otpapp
EnvironmentFile=/etc/otpapp/secrets.env
ExecStart=/opt/otpapp/venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 --access-logfile - app:app
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=/opt/otpapp
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now otpapp
sudo systemctl status otpapp
Nginx reverse proxy:
# /etc/nginx/sites-available/otpapp
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
server {
listen 443 ssl;
http2 on;
server_name portal.lab.local;
ssl_certificate /etc/ssl/certs/portal.lab.local.crt;
ssl_certificate_key /etc/ssl/private/portal.lab.local.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location /login {
limit_req zone=login burst=5 nodelay; # ⭐ lớp chống brute-force thứ hai
proxy_pass http://127.0.0.1:5000;
include /etc/nginx/proxy_params;
}
location / {
proxy_pass http://127.0.0.1:5000;
include /etc/nginx/proxy_params;
}
}
server {
listen 80;
server_name portal.lab.local;
return 301 https://$host$request_uri;
}
sudo ln -s /etc/nginx/sites-available/otpapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
📌 Đằng sau reverse proxy, rate limit theo IP sẽ thấy IP của nginx chứ không phải IP thật. Nếu app cần biết IP thật (để log hoặc rate limit ở tầng app), thêm ProxyFix:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
Chỉ dùng khi bạn chắc chắn app chỉ nhận request từ proxy tin cậy — nếu không, header X-Forwarded-For có thể bị giả mạo.
8.5. Kiến trúc C — App tự quản lý TOTP bằng pyotp
Dùng khi app phải hoạt động độc lập hoàn toàn với OTP server.
pip install pyotp "qrcode[pil]" cryptography argon2-cffi
Bốn nguyên tắc phải làm đúng — đây là phần phân biệt code chạy được và code dùng được:
1. Mã hoá seed trong database. Seed plaintext trong DB = một lỗ hổng SQL injection là mất toàn bộ MFA.
from cryptography.fernet import Fernet
fernet = Fernet(os.environ["OTP_FERNET_KEY"].encode()) # key lấy từ Vault/systemd, KHÔNG hardcode
def encrypt_secret(s: str) -> str: return fernet.encrypt(s.encode()).decode()
def decrypt_secret(e: str) -> str: return fernet.decrypt(e.encode()).decode()
2. Chống replay — điểm bị bỏ sót nhiều nhất.
pyotp.verify(code, valid_window=1) chấp nhận 3 mã trong 90 giây. Chỉ gọi hàm này thì kẻ tấn công nhìn trộm được mã vẫn dùng lại được trong cửa sổ đó. Cách chặn: lưu bước thời gian (counter) của mã vừa dùng, từ chối mọi mã có counter ≤ giá trị đã lưu.
import hmac, time, pyotp
TOTP_PERIOD, VALID_WINDOW = 30, 1
def verify_totp_no_replay(user_row, code: str) -> tuple[bool, str]:
totp = pyotp.TOTP(decrypt_secret(user_row["totp_secret_enc"]),
digits=6, interval=TOTP_PERIOD)
current = int(time.time()) // TOTP_PERIOD
matched = None
for off in range(-VALID_WINDOW, VALID_WINDOW + 1):
c = current + off
# compare_digest = so sánh constant-time, chống timing attack
if hmac.compare_digest(totp.at(c * TOTP_PERIOD), code):
matched = c
break
if matched is None:
return False, "Ma OTP khong dung"
if matched <= (user_row["last_totp_counter"] or 0):
return False, "Ma nay da duoc su dung roi (replay)"
db.execute("UPDATE users SET last_totp_counter=?, failed_attempts=0 WHERE id=?",
(matched, user_row["id"]))
db.commit()
return True, "OK"
3. Rate limiting. Mã 6 số + window ±1 = 3/1.000.000 mỗi lần thử. Không giới hạn = brute-force khả thi. Khoá tài khoản sau 5 lần sai.
4. Không cấp session trước khi qua yếu tố 2. Sau khi verify mật khẩu, chỉ lưu pending_uid có thời hạn ngắn. Session thật chỉ được tạo sau khi OTP đúng, kèm session.clear() để chống session fixation.
Enroll:
secret = pyotp.random_base32() # 32 ký tự Base32 = 160 bit, đúng khuyến nghị RFC
# LƯU secret (đã mã hoá) TRƯỚC, rồi mới render QR
db.execute("UPDATE users SET totp_secret_enc=? WHERE id=?", (encrypt_secret(secret), uid))
uri = pyotp.TOTP(secret, digits=6, interval=30)\
.provisioning_uri(name=username, issuer_name="LAB Internal Portal")
img = qrcode.make(uri) # render TẠI CHỖ — tuyệt đối không dùng dịch vụ QR online
⚠️ Bẫy: gọi pyotp.random_base32() hai lần (một lần để lưu DB, một lần để render QR) → seed trong DB khác seed trên điện thoại → mã luôn sai ngay từ lần đầu. Sinh secret một lần, lưu trước, rồi mới render.
8.6. Thư viện tương đương cho ngôn ngữ khác
| Ngôn ngữ | Thư viện | License | Ghi chú |
|---|---|---|---|
| Python | pyotp |
MIT | Chuẩn de-facto |
| PHP | spomky-labs/otphp |
MIT | ⚠️ TOTP::generate() mặc định sinh secret 64 byte → URI/QR rất lớn. Dùng TOTP::generate(20) |
| PHP (Laravel) | pragmarx/google2fa-laravel |
MIT | |
| Node.js | otplib |
MIT | ⚠️ Setter authenticator.options = {...} là merge, không replace — dùng authenticator.clone({epoch}) để tính mã tại một mốc thời gian cụ thể. speakeasy đã ngừng bảo trì |
| Go | github.com/pquerna/otp |
Apache 2.0 | |
| Java | com.eatthepath:java-otp |
MIT | |
| C#/.NET | Otp.NET |
MIT | |
| Ruby | rotp |
MIT | |
| Rust | totp-rs |
MIT |
📌 Nguyên tắc chống replay ở trên đúng cho MỌI ngôn ngữ. Hầu hết thư viện chỉ cung cấp verify(code) và không trả về counter đã khớp — bạn phải tự lặp qua window để lấy counter. Đây là lý do đoạn code ở 8.5 dùng vòng lặp thủ công thay vì gọi thẳng totp.verify().
8.7. Test
# ── 1. Test API trực tiếp ──────────────────────────────────────
curl -s -k -X POST https://otp01.lab.local/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD123456" \
| jq '{ket_qua: .result.value, ly_do: .detail.message, token: .detail.serial}'
# ── 2. Mã sai phải bị từ chối ──────────────────────────────────
curl -s -k -X POST https://otp01.lab.local/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD000000" \
| jq '.result.value' # -> false
# ── 3. Replay: dùng lại mã vừa dùng -> false ───────────────────
# ── 4. Endpoint RADIUS ─────────────────────────────────────────
curl -s -k -o /dev/null -w "HTTP %{http_code}\n" -X POST \
https://otp01.lab.local/validate/radiuscheck \
-d "user=hoangnv" -d "realm=lab" -d "pass=MatKhauAD123456"
# 204 = OK, 400 = fail
# ── 5. Test client library ─────────────────────────────────────
cd /opt/otpapp && ./venv/bin/python -c "
from pi_client import PrivacyIDEAClient
pi = PrivacyIDEAClient('https://otp01.lab.local', realm='lab',
verify='/usr/local/share/ca-certificates/lab-ca.crt')
r = pi.check('hoangnv', 'MatKhauAD123456')
print('ok=', r.ok, '| serial=', r.serial, '| msg=', r.message)
"
# ── 6. Test fail-closed: dừng Apache trên OTP01 rồi thử login ──
# -> app phải trả HTTP 503, KHÔNG được cho vào
Kịch bản test đã chạy thực tế khi soạn tài liệu (dùng mock server, xác nhận logic parse response):
ma dung -> True TOTP0001 totp
ma sai -> False | wrong otp value | challenge: False
challenge -> False | tx: 999 | is_challenge: True
radiuscheck GOOD -> True
radiuscheck BAD -> False
enroll -> TOTP0001 otpauth://totp/x?secret=ABC verify
server chet -> raise dung: PrivacyIDEAError
8.8. Tóm tắt Phần 8
Các bước chính:
- Chọn Kiến trúc A (gọi REST API) — người dùng chỉ cầm một token cho tất cả.
- 🔴 Đọc
result.value, không đọcresult.status. Nhầm là lỗ hổng xác thực. - Bảo vệ
/validate/check: giới hạn IP bằng policy + API key + rate limit ở Apache. - Client: không retry POST xác thực (gây replay),
verify=Truevới CA nội bộ, fail closed khi server chết. - Không cấp session trước khi qua yếu tố 2;
session.clear()sau khi thành công. - Deploy: venv (PEP 668), gunicorn + systemd hardening, nginx TLS +
limit_req. - Nếu dùng Kiến trúc C: mã hoá seed, chống replay bằng counter, rate limit, sinh secret một lần.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
| Mọi mã OTP sai đều được chấp nhận | 🔴 Kiểm result.status thay vì result.value |
Sửa ngay — đây là lỗ hổng xác thực |
| Mã đúng nhưng báo replay | Client retry POST tự động | allowed_methods=frozenset(["GET"]) trong Retry |
SSLError: certificate verify failed |
CA nội bộ chưa được tin cậy | update-ca-certificates, hoặc trỏ verify= tới file CA |
| App cho đăng nhập khi OTP server chết | Fail open | Sửa thành fail closed (HTTP 503) |
| 401 dù JWT đúng | Dùng Authorization: Bearer <jwt> |
Đổi thành PI-Authorization: <jwt> (không Bearer) |
pip install báo externally-managed-environment |
PEP 668 của Ubuntu 24.04 | Dùng venv |
| Rate limit luôn thấy IP của nginx | Thiếu ProxyFix |
Thêm ProxyFix, nhưng chỉ khi app chỉ nhận request từ proxy tin cậy |
| Challenge-response không hoạt động | Không xử lý transaction_id |
Bắt res.is_challenge và gọi lại kèm transaction_id |
| (Kiến trúc C) Cùng mã dùng nhiều lần | Chỉ gọi totp.verify() mà không lưu counter |
Implement verify_totp_no_replay() ở 8.5 |
| (Kiến trúc C) Mã luôn sai ngay từ đầu | Gọi random_base32() hai lần |
Sinh secret một lần, lưu trước, rồi render QR |
PHẦN 9. BACKUP, DR VÀ HA
9.1. 🔴 Hiểu đúng rủi ro trước khi làm
Backup của OTP server có hai mặt, và cả hai đều nghiêm trọng:
Mất backup = mất seed của toàn bộ tổ chức. Không có cách khôi phục nào ngoài việc enroll lại từng người. Với 500 nhân viên, đó là vài ngày công IT và một sự cố lớn.
Lộ backup = kẻ tấn công sinh được OTP của mọi người, vĩnh viễn, mà không ai biết. Backup OTP nguy hiểm hơn backup của hầu hết hệ thống khác — nó phải được mã hoá.
9.2. Cái gì cần backup
| Thành phần | Đường dẫn | Bắt buộc | Ghi chú |
|---|---|---|---|
| Khoá mã hoá | /etc/privacyidea/enckey |
🔴🔴 SỐNG CÒN | Mã hoá mọi seed. Mất = mất hết. pi-manage backup mặc định KHÔNG kèm file này |
| Database | DB pi (MariaDB) |
🔴 SỐNG CÒN | Chứa token, user assignment, policy, audit |
| Cấu hình | /etc/privacyidea/pi.cfg |
🔴 SỐNG CÒN | Chứa SECRET_KEY, PI_PEPPER, DB URI |
| Khoá ký audit | /etc/privacyidea/private.pem + public.pem |
🟠 Cao | Thiếu thì audit log cũ không verify được |
| Cấu hình RADIUS | /etc/freeradius/3.0/ + /etc/privacyidea/rlm_perl.ini |
🟠 Cao | Chứa shared secret của mọi client |
| Chứng chỉ TLS | /etc/ssl/certs/privacyideaserver.pem + key |
🟡 Trung bình | Cấp lại được từ CA |
| Khoá PGP | /etc/privacyidea/gpg/ |
🟡 Trung bình | |
| Log ứng dụng | /var/log/privacyidea/ |
🟢 Thấp | Giữ cho tuân thủ |
🔴 Điểm dễ sai nhất và hậu quả nặng nhất:
pi-manage backup create mặc định KHÔNG kèm enckey. Bạn phải thêm cờ -e.
Nếu không, bạn sẽ có một file backup trông rất bình thường, restore rất suôn sẻ — và không giải mã được một seed nào. Bạn chỉ phát hiện ra vào đúng lúc tệ nhất.
Khi restore không có enckey, privacyIDEA có in cảnh báo màu vàng:
NO FILE 'enckey' CONTAINED! BE SURE TO RESTORE THE ENCRYPTION KEY MANUALLY!
Nhưng lúc đó thường đã quá muộn.
9.3. Backup thủ công — hiểu cơ chế
# ── Backup chuẩn, CÓ enckey ────────────────────────────────────
sudo pi-manage backup create -e
# Mặc định ghi ra: /var/lib/privacyidea/backup/privacyidea-backup-YYYYmmdd-HHMM.tgz
sudo ls -lh /var/lib/privacyidea/backup/
# ── Backup kèm cả cấu hình FreeRADIUS ──────────────────────────
sudo pi-manage backup create -e \
-d /var/backups/privacyidea \
-c /etc/privacyidea/ \
-r /etc/freeradius/3.0
| Cờ | Ý nghĩa |
|---|---|
-e |
⭐ Kèm enckey. Bắt buộc |
-d <dir> |
Thư mục đích |
-c <dir> |
Thư mục cấu hình (mặc định /etc/privacyidea/) |
-r <dir> |
Thư mục cấu hình FreeRADIUS |
Nội dung file .tgz: dump database (mysqldump --single-transaction --skip-lock-tables) + toàn bộ /etc/privacyidea.
Ba hành vi cần biết:
- File backup được
chmod 0600tự động. - Script tạo tạm
/etc/privacyidea/mysql.cnfchứa credential DB (để không lộ mật khẩu trên command line), rồi xoá. - Nếu
mysqldumpfail, script xoá dump dở và exit 2 — không đóng gói backup lỗi. Hành vi này tốt, nhưng nghĩa là bạn phải kiểm tra exit code.
⚠️ pi-manage backup chỉ hỗ trợ SQLite và MySQL/MariaDB. Với PostgreSQL nó báo unsupported SQL syntax và exit 2. Đây là một lý do nữa để dùng MariaDB (cũng là mặc định của gói).
📌 pi-manage config export KHÁC backup. Nó chỉ xuất cấu hình logic (policy, resolver, realm, event) — không có token. Không dùng để phục hồi thảm hoạ. Và mặc định nó xuất secret ở dạng plaintext (mật khẩu bind LDAP, RADIUS secret, mật khẩu SMTP) → luôn thêm --censor nếu file sẽ được lưu ở nơi ít an toàn hơn.
9.4. Script backup tự động
sudo tee /usr/local/bin/backup-privacyidea.sh > /dev/null <<'SCRIPT'
#!/bin/bash
#
# /usr/local/bin/backup-privacyidea.sh
# Backup privacyIDEA -> mã hoá GPG -> nhân bản ra 2 nơi -> dọn bản cũ
# Chạy hằng ngày 01:00 qua cron
#
set -euo pipefail
BAK_DIR="/var/backups/privacyidea"
REMOTE="[email protected]:/srv/backup/privacyidea/"
STANDBY="[email protected]:/var/backups/privacyidea/"
GPG_RECIPIENT="[email protected]" # public key GPG của bộ phận IT
RETAIN_DAYS=30
STAMP=$(date +%Y%m%d-%H%M%S)
LOG="/var/log/privacyidea-backup.log"
log() { echo "$(date '+%F %T') $*" | tee -a "$LOG"; }
fail() { log "!!! THAT BAI: $*"; logger -p daemon.err -t pi-backup "$*"; exit 1; }
log "=== BAT DAU BACKUP ==="
mkdir -p "$BAK_DIR"
# ── 1. Tạo backup CÓ enckey ────────────────────────────────────
# Cờ -e là bắt buộc. Không có nó, backup vô dụng khi restore.
if ! pi-manage backup create -e -d "$BAK_DIR" \
-c /etc/privacyidea/ -r /etc/freeradius/3.0 >>"$LOG" 2>&1; then
fail "pi-manage backup create loi"
fi
RAW=$(find "$BAK_DIR" -maxdepth 1 -name 'privacyidea-backup-*.tgz' -newermt '-5 minutes' \
| sort | tail -1)
[[ -n "$RAW" ]] || fail "Khong tim thay file backup vua tao"
log "Da tao: $RAW ($(du -h "$RAW" | cut -f1))"
# ── 2. Kiểm tra file backup THỰC SỰ chứa enckey ────────────────
# Đây là bước xác minh quan trọng nhất của script.
if ! tar tzf "$RAW" | grep -q 'enckey'; then
fail "BACKUP KHONG CHUA ENCKEY - VO DUNG KHI RESTORE. Kiem tra co -e"
fi
log "Xac nhan: backup CO chua enckey"
# ── 3. Mã hoá bằng GPG public key ──────────────────────────────
# Chỉ người giữ private key mới mở được -> server bị chiếm
# cũng không giải mã được backup cũ.
ENC="$BAK_DIR/privacyidea-$STAMP.tgz.gpg"
gpg --batch --yes --trust-model always \
--encrypt --recipient "$GPG_RECIPIENT" \
--output "$ENC" "$RAW" || fail "GPG ma hoa loi"
shred -u "$RAW" # xoá bản chưa mã hoá
chmod 600 "$ENC"
log "Da ma hoa: $ENC"
# ── 4. Checksum để kiểm tra toàn vẹn khi restore ───────────────
sha256sum "$ENC" > "$ENC.sha256"
log "SHA256: $(cut -d' ' -f1 < "$ENC.sha256")"
# ── 5. Nhân bản ra 2 nơi khác (quy tắc 3-2-1) ──────────────────
for DEST in "$REMOTE" "$STANDBY"; do
if rsync -a --timeout=60 "$ENC" "$ENC.sha256" "$DEST" 2>>"$LOG"; then
log "Da copy sang $DEST"
else
log "CANH BAO: khong copy duoc sang $DEST"
logger -p daemon.warning -t pi-backup "Khong copy duoc backup sang $DEST"
fi
done
# ── 6. Dọn bản cũ ──────────────────────────────────────────────
find "$BAK_DIR" -name 'privacyidea-*.tgz.gpg*' -mtime +$RETAIN_DAYS -print -delete \
| while read -r f; do log "Xoa ban cu: $f"; done
log "=== BACKUP THANH CONG ==="
SCRIPT
sudo chmod 700 /usr/local/bin/backup-privacyidea.sh
Tạo khoá GPG cho backup (chạy trên một máy khác, không phải OTP01):
# Trên máy quản trị của bạn
gpg --full-generate-key
# Chọn: RSA and RSA, 4096 bit, không hết hạn
# Real name: OTP Backup
# Email: [email protected]
# Export PUBLIC key mang sang OTP01
gpg --armor --export [email protected] > /tmp/otp-backup-pub.asc
# ⭐ Export PRIVATE key và cất vào KÉT / password manager của công ty.
# Đây là thứ duy nhất mở được backup.
gpg --armor --export-secret-keys [email protected] > otp-backup-private.asc
# Trên OTP01 — CHỈ import public key
sudo gpg --import /tmp/otp-backup-pub.asc
sudo gpg --list-keys
⭐ Vì sao dùng GPG public key thay vì mật khẩu? Vì OTP01 chỉ cần public key để mã hoá. Nếu server bị chiếm, kẻ tấn công không giải mã được các backup cũ — private key nằm ở nơi khác. Với mật khẩu đối xứng thì mật khẩu phải nằm trên server, và mất server là mất luôn.
Đăng ký cron:
echo '0 1 * * * root /usr/local/bin/backup-privacyidea.sh >/dev/null 2>&1' \
| sudo tee /etc/cron.d/privacyidea-backup
# Chạy thử ngay
sudo /usr/local/bin/backup-privacyidea.sh
sudo tail -20 /var/log/privacyidea-backup.log
Giám sát backup — một backup thất bại âm thầm còn tệ hơn không có backup:
sudo tee /usr/local/bin/check-backup-age.sh > /dev/null <<'EOF'
#!/bin/bash
# Cảnh báo nếu backup mới nhất cũ hơn 26 giờ
LATEST=$(find /var/backups/privacyidea -name '*.tgz.gpg' -mtime -1.1 | head -1)
if [[ -z "$LATEST" ]]; then
logger -p daemon.crit -t pi-backup "KHONG CO BACKUP trong 26 gio qua"
echo "CRITICAL: khong co backup moi"; exit 2
fi
echo "OK: backup moi nhat $LATEST"; exit 0
EOF
sudo chmod +x /usr/local/bin/check-backup-age.sh
echo '0 8 * * * root /usr/local/bin/check-backup-age.sh' | sudo tee -a /etc/cron.d/privacyidea-backup
9.5. Quy trình restore — viết ra và diễn tập
💡 Một bản backup chưa từng được restore thử thì không phải backup — đó chỉ là một file. Đặt lịch diễn tập DR 6 tháng/lần vào calendar và coi nó nghiêm túc như patch Tuesday.
Kịch bản: OTP01 hỏng hoàn toàn, dựng lại từ backup.
# ══ B1: Dựng Ubuntu 24.04 mới, ĐẶT ĐÚNG hostname và IP CŨ ══════
# Đổi IP nghĩa là phải sửa cấu hình ở TẤT CẢ client.
sudo hostnamectl set-hostname otp01.lab.local
# ... cấu hình netplan như mục 1.5.2 ...
# ══ B2: Đồng bộ thời gian TRƯỚC KHI làm gì tiếp ════════════════
sudo apt install -y chrony
sudo chronyc makestep
chronyc tracking # PHẢI đồng bộ trước khi restore
# ══ B3: Cài privacyIDEA (chưa cấu hình gì) ═════════════════════
# ... theo Phần 2.1 - 2.2 ...
sudo apt install -y privacyidea-apache2 freeradius freeradius-utils
# ══ B4: KIỂM TRA TOÀN VẸN file backup TRƯỚC khi dùng ═══════════
cd /var/backups/privacyidea
sha256sum -c privacyidea-20260801-010000.tgz.gpg.sha256 \
|| { echo "CHECKSUM SAI - FILE BACKUP HONG"; exit 1; }
# ══ B5: Giải mã (cần PRIVATE key GPG từ két) ══════════════════
gpg --import /media/usb/otp-backup-private.asc
gpg --decrypt --output privacyidea-restore.tgz privacyidea-20260801-010000.tgz.gpg
# ══ B6: XÁC NHẬN backup có enckey trước khi restore ═══════════
tar tzf privacyidea-restore.tgz | grep enckey \
|| { echo "BACKUP KHONG CO ENCKEY - KHONG THE KHOI PHUC SEED"; exit 1; }
# ══ B7: Restore ═══════════════════════════════════════════════
# ⚠️ Lệnh này giải nén thẳng ra / — KHÔNG chọn được thư mục đích.
# Chỉ chạy trên máy sạch.
sudo pi-manage backup restore privacyidea-restore.tgz
# Nếu DB URI trên máy mới khác với trong backup:
# sudo pi-manage backup restore privacyidea-restore.tgz --keep-db-uri
# ══ B8: Khôi phục dịch vụ ═════════════════════════════════════
sudo systemctl restart apache2 mariadb freeradius
sudo ufw allow 443/tcp && sudo ufw allow 1812/udp && sudo ufw allow 1813/udp
# ══ B9: VERIFY — bước không được bỏ qua ═══════════════════════
sudo pi-manage config realm list # đủ realm?
sudo pi-manage config resolver list # đủ resolver?
sudo pi-manage admin list # đủ admin?
sudo mysql pi -e "SELECT COUNT(*) AS so_token FROM token;"
Verify bằng một token thật — đây mới là bằng chứng restore thành công:
# Trên OTP01
curl -s -k -X POST https://otp01.lab.local/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=<mat-khau><OTP-that-tu-dien-thoai>" \
| jq '.result.value'
# PHẢI là true. Nếu false -> enckey không đúng -> seed không giải mã được.
# Từ LNX01
radtest hoangnv '<mat-khau><OTP>' 192.168.10.10:1812 0 'SECRET'
# Access-Accept
# Từ WINSRV02: RDP vào -> phải hỏi OTP và đăng nhập được
Chỉ tiêu RTO/RPO:
| Chỉ số | Mục tiêu | Cách đạt |
|---|---|---|
| RPO (mất tối đa bao nhiêu dữ liệu) | ≤ 24 giờ | Backup hằng ngày 01:00. Nếu enroll nhiều thì 2 lần/ngày |
| RTO (bao lâu khôi phục xong) | ≤ 2 giờ thủ công; ≤ 5 phút nếu có OTP02 | Duy trì node thứ hai (mục 9.6) |
| Tần suất diễn tập | 6 tháng/lần | Restore vào VM cách ly, verify bằng token thật |
9.6. Node thứ hai (OTP02) — HA và failover
privacyIDEA hỗ trợ nhiều app node dùng chung một database. Đây là điểm hơn rõ rệt so với multiOTP (nơi HA là tính năng trả phí).
9.6.1. Kiến trúc
9.6.2. Dựng OTP02
# Trên OTP02 — cài y hệt OTP01
sudo apt install -y privacyidea-apache2 freeradius freeradius-utils
# ⭐ Copy các file KHOÁ từ OTP01 — bắt buộc phải GIỐNG HỆT.
# Khác enckey = không giải mã được seed trong DB chung.
sudo scp [email protected]:/etc/privacyidea/enckey /etc/privacyidea/
sudo scp [email protected]:/etc/privacyidea/private.pem /etc/privacyidea/
sudo scp [email protected]:/etc/privacyidea/public.pem /etc/privacyidea/
sudo scp [email protected]:/etc/privacyidea/pi.cfg /etc/privacyidea/
sudo chown privacyidea:root /etc/privacyidea/{enckey,private.pem,public.pem,pi.cfg}
sudo chmod 600 /etc/privacyidea/enckey /etc/privacyidea/private.pem
sudo chmod 640 /etc/privacyidea/pi.cfg
Sửa pi.cfg trên OTP02 trỏ vào DB chung:
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://pi:<mat-khau>@192.168.10.50/pi?charset=utf8'
PI_BASE_URL = 'https://otp02.lab.local'
# ⭐ Bắt buộc khi dùng Galera Cluster
PI_DB_SAFE_STORE = True
sudo systemctl restart apache2
curl -s -k -X POST https://otp02.lab.local/auth \
-d "username=admin" -d "password=<pw>" | jq '.result.value.role'
# -> "admin"
Copy cấu hình FreeRADIUS:
sudo scp [email protected]:/etc/privacyidea/rlm_perl.ini /etc/privacyidea/
sudo scp -r [email protected]:/etc/freeradius/3.0/clients.conf /etc/freeradius/3.0/
sudo scp [email protected]:/etc/freeradius/3.0/mods-available/privacyidea /etc/freeradius/3.0/mods-available/
sudo ln -sf ../mods-available/privacyidea /etc/freeradius/3.0/mods-enabled/privacyidea
# ... sửa sites-available/default như mục 5.5.2 ...
sudo systemctl restart freeradius
Sửa URL trong rlm_perl.ini trên OTP02 trỏ về chính nó:
URL = https://otp02.lab.local/validate/check
9.6.3. Nếu không muốn HA đầy đủ — chế độ warm standby
Đơn giản hơn nhiều: OTP02 cài sẵn phần mềm nhưng dùng DB riêng, đồng bộ định kỳ từ OTP01.
sudo tee /usr/local/bin/sync-to-otp02.sh > /dev/null <<'EOF'
#!/bin/bash
# Đồng bộ OTP01 -> OTP02 mỗi 15 phút (một chiều)
set -euo pipefail
STANDBY="[email protected]"
# 1. Dump DB rồi nạp sang standby
mysqldump --single-transaction --skip-lock-tables pi \
| ssh "$STANDBY" "mysql pi" || { logger -t pi-sync "Sync DB THAT BAI"; exit 1; }
# 2. Đồng bộ các file khoá (chỉ khi thay đổi)
rsync -a /etc/privacyidea/{enckey,private.pem,public.pem} "$STANDBY:/etc/privacyidea/"
logger -t pi-sync "Da dong bo sang OTP02"
EOF
sudo chmod 700 /usr/local/bin/sync-to-otp02.sh
echo '*/15 * * * * root /usr/local/bin/sync-to-otp02.sh >/dev/null 2>&1' \
| sudo tee /etc/cron.d/pi-sync
⚠️ Giới hạn của warm standby: replication một chiều nghĩa là mọi thay đổi trên OTP02 (kể cả counter chống replay của HOTP) sẽ bị ghi đè ở lần sync tiếp theo. Chấp nhận được cho DR ngắn hạn, không phải active-active thật.
Cụ thể hơn: nếu OTP01 chết và bạn chạy trên OTP02 trong 2 giờ, rồi OTP01 sống lại — bạn phải sync ngược OTP02 → OTP01, nếu không mọi token enroll trong 2 giờ đó sẽ biến mất.
9.6.4. Cấu hình failover ở từng loại client
| Client | Cơ chế failover |
|---|---|
| Windows Credential Provider | ⭐ Registry fallback_hostname = otp02.lab.local. Native, không cần load balancer |
Linux pam_radius |
Hai dòng trong /etc/pam_radius_auth.conf — tự chuyển sau khi timeout |
| Web app (client Python) | Sửa PrivacyIDEAClient nhận danh sách URL, thử lần lượt |
| Thiết bị mạng (VPN, switch) | Hầu hết hỗ trợ khai 2 RADIUS server |
| Tất cả — cách tổng quát | HAProxy hoặc keepalived + VIP đứng trước cả hai node |
Chuẩn bị DNS để đổi nhanh:
otp.lab.local. 60 IN A 192.168.10.10
Đặt TTL = 60 giây ngay từ đầu, và cấu hình mọi client trỏ vào otp.lab.local chứ không phải IP. Khi sự cố, chỉ cần đổi một bản ghi DNS.
9.7. Kịch bản sự cố
Kịch bản 1 — OTP01 chết lúc 9h sáng
1. Xác nhận: curl -sk https://otp01.lab.local/ -> không phản hồi
2. Các client CÓ failover tự chuyển sau ~10 giây, không cần làm gì:
· Linux pam_radius -> sang OTP02
· Windows CP -> sang fallback_hostname
· Web app -> sang URL thứ 2 trong danh sách
· Thiết bị mạng -> sang RADIUS server thứ 2
3. Với client CHƯA cấu hình failover:
· Đổi bản ghi DNS otp.lab.local -> 192.168.10.11 (TTL 60s đã đặt sẵn)
4. Thông báo người dùng: "MFA đang chạy trên hệ thống dự phòng, có thể chậm hơn"
5. Khôi phục OTP01 theo quy trình 9.5
6. ⚠️ Sau khi OTP01 sống lại: nếu dùng warm standby, phải SYNC NGƯỢC
từ OTP02 về OTP01 — nếu không sẽ mất token enroll trong lúc sự cố
Kịch bản 2 — Cả OTP01 và OTP02 đều chết
1. Dùng đường vào BREAK-GLASS đã chuẩn bị:
· Windows : tài khoản trong excluded_account
· Linux : SSH key admin + Match User bỏ qua OTP, hoặc console qua IPMI
· Web app : không có -> đây là lý do phải fail closed một cách CÓ CHỦ Ý
2. Nếu cần mở khẩn cấp cho nhiều người:
· Windows : GPO đẩy registry cpus_logon="3d" (3 = TẮT HOÀN TOÀN)
⚠️ KHÔNG phải 0 — giá trị 0 nghĩa là BẬT cho cả remote lẫn local
· Linux : comment dòng pam_radius trong /etc/pam.d/sshd (qua Ansible/console)
3. Khôi phục từ backup off-site theo 9.5
4. Sau sự cố: viết post-mortem, ghi lại thời gian THỰC TẾ của từng bước
để hiệu chỉnh lại RTO
Kịch bản 3 — Người dùng mất/hỏng/đổi điện thoại
Đây là việc xảy ra hằng tuần, nên quy trình phải rõ ràng và có bước xác minh danh tính.
B1. XÁC MINH NGƯỜI YÊU CẦU
🔴 Đây chính là lỗ hổng social engineering phổ biến nhất của MỌI hệ thống MFA.
Kẻ tấn công gọi helpdesk, nói "tôi mất điện thoại", và được cấp token mới.
· Gọi lại theo số điện thoại có trong hệ thống HR (KHÔNG gọi số họ cung cấp)
· Hoặc: quản lý trực tiếp xác nhận qua kênh riêng
· Hoặc: gặp mặt trực tiếp, xem thẻ nhân viên
· Ghi lại: ai yêu cầu, ai xác minh, lúc mấy giờ, bằng cách nào
B2. VÔ HIỆU HOÁ TOKEN CŨ NGAY
Điện thoại có thể đang trong tay người khác.
WebUI -> Tokens -> chọn token -> Disable (hoặc Delete)
Hoặc API:
curl -k -X POST $PI_URL/token/disable -H "PI-Authorization: $PI_TOKEN" \
-d "serial=TOTP0001A2B3"
B3. CẤP TOKEN MỚI
· Ưu tiên: gửi link self-service để user tự enroll (seed không qua tay admin)
· Hoặc admin enroll qua WebUI khi user có mặt
B4. Ghi log vào hệ thống ticket
B5. Nếu NGHI NGỜ điện thoại bị đánh cắp có chủ đích:
· Đổi mật khẩu AD của user
· Kiểm tra audit log 30 ngày gần nhất tìm hoạt động bất thường:
curl -sk -G $PI_URL/audit/ -H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "user=hoangnv" --data-urlencode "page_size=100" | jq
· Thu hồi mọi session đang hoạt động
Bốn cách giảm tải cho helpdesk:
| Cách | Mô tả | Đánh đổi |
|---|---|---|
max_active_token_per_user=2 ⭐ |
Mỗi user có 2 token: điện thoại chính + KeePassXC trên máy làm việc | Rẻ nhất, hiệu quả nhất. Đã cấu hình ở mục 4.3 |
| Self-service enrollment | User tự đăng ký lại sau khi xác thực bằng yếu tố khác | Tiện, nhưng chính là điểm yếu nếu email đã bị chiếm |
| Token cứng cho VIP | YubiKey/Token2 cho admin và lãnh đạo | Tốn tiền nhưng không mất theo điện thoại |
| Token giấy (paper token) | privacyIDEA hỗ trợ sẵn — in danh sách mã dùng một lần | Phải cất giữ cẩn thận |
9.8. Tài khoản break-glass
# ══ Linux ══════════════════════════════════════════════════════
sudo useradd -m -s /bin/bash brk-glass
sudo usermod -aG sudo brk-glass
sudo -u brk-glass ssh-keygen -t ed25519 -f /home/brk-glass/.ssh/id_ed25519 -C "break-glass"
# Cất private key vào USB trong két, xoá khỏi server
# /etc/ssh/sshd_config
Match User brk-glass
AuthenticationMethods publickey
PubkeyAuthentication yes
KbdInteractiveAuthentication no
# ══ Windows ════════════════════════════════════════════════════
PS> $pw = ConvertTo-SecureString (-join ((33..126) | Get-Random -Count 28 | %{[char]$_})) -AsPlainText -Force
PS> New-LocalUser -Name "brk-glass" -Password $pw -PasswordNeverExpires
PS> Add-LocalGroupMember -Group "Administrators" -Member "brk-glass"
PS> Set-ItemProperty -Path "HKLM:\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP" `
-Name "excluded_account" -Type String -Value "$env:COMPUTERNAME\brk-glass"
Cảnh báo khi tài khoản này được dùng:
# Linux — gửi cảnh báo qua syslog/SIEM khi brk-glass đăng nhập
sudo tee /etc/pam.d/sshd.d-brkglass-alert > /dev/null <<'EOF'
session optional pam_exec.so /usr/local/bin/alert-breakglass.sh
EOF
sudo tee /usr/local/bin/alert-breakglass.sh > /dev/null <<'EOF'
#!/bin/bash
[[ "$PAM_USER" == "brk-glass" ]] && \
logger -p auth.crit -t breakglass "CANH BAO: tai khoan break-glass dang nhap tu $PAM_RHOST"
exit 0
EOF
sudo chmod +x /usr/local/bin/alert-breakglass.sh
Checklist quản lý break-glass:
- Mật khẩu ≥ 25 ký tự ngẫu nhiên, niêm phong trong két hoặc password manager có audit trail
- Ít nhất 2 người biết cách truy cập (tránh bus factor = 1)
- Alert tự động khi tài khoản được sử dụng, gửi tới điện thoại admin
- Đổi mật khẩu sau mỗi lần dùng
- Kiểm tra định kỳ 3 tháng/lần xem có còn đăng nhập được không
- Ghi vào runbook DR: ai giữ, giữ ở đâu, dùng thế nào
9.9. Tóm tắt Phần 9
Các bước chính:
- 🔴
pi-manage backup createPHẢI có cờ-e— mặc định không kèmenckey. - Script backup tự kiểm tra file backup có chứa
enckeykhông, và fail nếu không có. - Mã hoá bằng GPG public key — private key cất ở nơi khác, server bị chiếm không giải mã được backup cũ.
- Nhân bản ra ≥ 2 nơi (quy tắc 3-2-1), có checksum, có giám sát tuổi backup.
- Restore giải nén thẳng ra
/— chỉ chạy trên máy sạch. - Verify restore bằng một token thật, không chỉ đếm số dòng trong DB.
- Dựng OTP02 dùng chung DB (HA thật) hoặc warm standby (đơn giản hơn). Copy
enckeygiống hệt. - Cấu hình failover ở từng loại client, và đặt DNS TTL = 60 giây.
- Quy trình reset token bắt buộc có bước xác minh danh tính.
- Break-glass: có, test, alert, đổi mật khẩu sau mỗi lần dùng.
Lỗi thường gặp:
| Triệu chứng | Nguyên nhân | Xử lý |
|---|---|---|
| Restore xong nhưng không ai đăng nhập được | Backup thiếu enckey |
Luôn dùng -e; kiểm tra bằng tar tzf backup.tgz \| grep enckey |
pi-manage backup create báo unsupported SQL syntax |
Dùng PostgreSQL | Chuyển sang MariaDB |
| Backup fail âm thầm trên Ubuntu 24.04 | Bug Python 3.12 ở bản < 3.13.1 | Nâng lên ≥ 3.13.1 |
| Restore ghi đè lung tung lên máy đang chạy | backup restore giải nén ra / |
Chỉ restore trên máy sạch hoặc VM cách ly |
| OTP02 không đọc được token | enckey khác OTP01 |
Copy enckey giống hệt từ OTP01 |
| Sau sự cố, token enroll lúc chạy OTP02 biến mất | Warm standby là replication một chiều | Sync ngược trước khi chuyển về OTP01 |
| Không mở được file backup của chính mình | Mất GPG private key | Cất private key ở 2 nơi, ≥ 2 người biết |
| Backup không chạy hàng tuần liền mà không ai biết | Không giám sát | Cài check-backup-age.sh |
| Kẻ tấn công lấy được token mới qua helpdesk | Không có bước xác minh danh tính | Áp dụng quy trình B1 ở Kịch bản 3 |
PHẦN 10. TROUBLESHOOTING VÀ PHỤ LỤC
10.1. Phương pháp debug — đi từ trong ra ngoài
Khi có sự cố, đừng đoán. Kiểm tra theo đúng thứ tự này, mỗi tầng loại trừ một nhóm nguyên nhân:
Tầng 1: THỜI GIAN chronyc tracking ← luôn kiểm tra đầu tiên
↓
Tầng 2: ENGINE OTP curl /validate/check ngay trên OTP01
↓
Tầng 3: MẠNG tcpdump / ss / ufw status
↓
Tầng 4: RADIUS freeradius -X + radtest
↓
Tầng 5: CLIENT PAM / Credential Provider / web app
💡 80% sự cố TOTP nằm ở Tầng 1 hoặc Tầng 3. Kiểm tra hai tầng đó trước khi đọc code hay lục cấu hình.
Bộ lệnh chẩn đoán nhanh trên OTP01:
sudo tee /usr/local/bin/pi-diag.sh > /dev/null <<'EOF'
#!/bin/bash
echo "═══ 1. THOI GIAN ══════════════════════════════════════"
timedatectl status | grep -E 'Local time|synchronized|Time zone'
chronyc tracking | grep -E 'System time|Leap status'
echo; echo "═══ 2. DICH VU ════════════════════════════════════════"
for s in apache2 mariadb freeradius chrony; do
printf "%-12s : %s\n" "$s" "$(systemctl is-active $s)"
done
echo; echo "═══ 3. CONG LANG NGHE ═════════════════════════════════"
ss -tlnp 2>/dev/null | grep -E ':443|:3306' | awk '{print $4, $6}'
ss -ulnp 2>/dev/null | grep -E ':1812|:1813' | awk '{print $4, $6}'
echo; echo "═══ 4. FIREWALL ═══════════════════════════════════════"
ufw status | head -12
echo; echo "═══ 5. PHIEN BAN ══════════════════════════════════════"
apt-cache policy privacyidea 2>/dev/null | grep Installed
freeradius -v 2>/dev/null | head -1
echo; echo "═══ 6. QUYEN FILE QUAN TRONG ══════════════════════════"
ls -l /etc/privacyidea/{enckey,pi.cfg,private.pem} 2>/dev/null
echo; echo "═══ 7. LOG GAN NHAT (loi) ═════════════════════════════"
tail -15 /var/log/privacyidea/privacyidea.log 2>/dev/null | grep -iE 'error|fail|warn' || echo "(khong co loi gan day)"
echo; echo "═══ 8. BACKUP GAN NHAT ════════════════════════════════"
ls -lht /var/backups/privacyidea/*.gpg 2>/dev/null | head -3 || echo "(KHONG CO BACKUP!)"
echo; echo "═══ 9. DUNG LUONG ═════════════════════════════════════"
df -h / | tail -1
EOF
sudo chmod +x /usr/local/bin/pi-diag.sh
sudo /usr/local/bin/pi-diag.sh
10.2. Bảng lỗi tổng hợp toàn hệ thống
| # | Triệu chứng | Nguyên nhân gốc | Cách xác định | Cách xử lý |
|---|---|---|---|---|
| 1 | Mã OTP luôn sai trên mọi hệ thống | Lệch giờ giữa OTP server và điện thoại | chronyc tracking |
Sửa NTP. Tạm nới timeWindow để xác nhận nguyên nhân, sau đó sửa NTP rồi siết lại |
| 2 | Chỉ một user bị sai mã | Điện thoại user lệch giờ | Hỏi user | Google Authenticator: ⋮ → Time correction for codes → Sync now; bật auto time |
| 3 | Sau restore snapshot VM, cả công ty hỏng MFA | Giờ nhảy về quá khứ | chronyc tracking |
chronyc makestep. Đưa bước resync vào quy trình restore snapshot |
| 4 | Mã đúng nhưng báo đã dùng rồi | Chống replay hoạt động đúng | Audit log | Đợi 30 giây lấy mã mới. Đây là tính năng |
| 5 | Token bị khoá | Vượt Max failcount |
WebUI → token → failcount | Reset Failcounter, hoặc POST /token/reset |
| 6 | Mọi mã OTP sai đều được chấp nhận (web app) | 🔴 Code kiểm result.status thay vì result.value |
Đọc code | Sửa ngay — lỗ hổng xác thực |
| 7 | apt update báo NO_PUBKEY |
Khoá GPG chưa cài đúng chỗ | ls /etc/apt/trusted.gpg.d/ |
Đặt file .asc vào đó, chmod 644 |
| 8 | Website khác ngừng chạy sau khi cài privacyIDEA | postinst xoá sites-enabled/* |
ls /etc/apache2/sites-enabled/ |
a2ensite <site>; chuyển privacyIDEA sang máy riêng |
| 9 | Apache không start | pi.cfg sai cú pháp Python |
python3 -c "exec(open('/etc/privacyidea/pi.cfg').read())" |
Sửa cú pháp; xem /var/log/apache2/error.log |
| 10 | pi-manage báo permission denied |
pi.cfg mode 640 — cố ý |
ls -l /etc/privacyidea/pi.cfg |
Chạy bằng sudo |
| 11 | API trả 401 dù JWT đúng | Dùng Authorization: Bearer <jwt> |
Xem request | Đổi thành PI-Authorization: <jwt> (không Bearer) |
| 12 | LDAP resolver báo 0 user | Sai Base DN hoặc Search Filter | ldapsearch với đúng filter đó |
Sửa filter |
| 13 | LDAP rất chậm/timeout | Chưa bật NOREFERRALS |
Test resolver trong WebUI | Bật No anonymous referral chase |
| 14 | Token mất liên kết với user sau khi chuyển OU | Dùng DN thay vì objectGUID làm UID |
Kiểm userid qua GET /user/ |
Đặt UIDTYPE=objectGUID trước khi enroll |
| 15 | otppin=userstore fail toàn bộ |
DC không tới được — mật khẩu kiểm bằng LDAP bind trực tiếp | ldapsearch |
Khai nhiều DC trong LDAPURI |
| 16 | Sau khi tạo policy admin đầu tiên, admin mất quyền |
Scope trống = cho phép tất cả; có policy = chỉ cho những gì liệt kê | WebUI → Policies | Thêm action còn thiếu, hoặc pi-manage config policy disable <ten> |
| 17 | QR code không hợp lệ | tokenlabel={user} mà enroll không gắn user |
Kiểm policy enroll_base |
Đổi thành {user}@{realm} |
| 18 | Can't locate URI/Encode.pm (FreeRADIUS) |
Thiếu liburi-encode-perl — tài liệu chính thức quên liệt kê |
freeradius -X |
add-apt-repository universe && apt install liburi-encode-perl |
| 19 | Config File ... not found! |
Sai đường dẫn hoặc quyền rlm_perl.ini |
freeradius -X |
Đặt /etc/privacyidea/rlm_perl.ini, chown root:freerad, chmod 640 |
| 20 | SSL không được verify dù đã bật | Ghi SSL_CHECK = 1 thay vì true |
Đọc file ini | Code khớp regex /true/i — phải ghi đúng true |
| 21 | 501 Protocol scheme 'https' is not supported |
Thiếu liblwp-protocol-https-perl |
freeradius -X |
Cài gói đó |
| 22 | Token PUSH/SMS không hoạt động | Dùng rlm_rest, hoặc site thiếu Post-Auth-Type Challenge |
Kiểm cấu hình site | Dùng rlm_perl và khôi phục section đó |
| 23 | MSCHAPv2 luôn fail | Giới hạn kiến trúc — không có plaintext password | — | Chuyển sang PAP, hoặc EAP-TLS |
| 24 | Access-Reject ngay lập tức |
Shared secret lệch (thường do dấu cách thừa cuối dòng) | cat -A clients.conf |
So từng ký tự |
| 25 | RADIUS timeout, không phản hồi | Firewall, hoặc IP client chưa khai | tcpdump -n udp port 1812 ở cả 2 đầu |
Mở firewall; khai client |
| 26 | SSH: chỉ bị hỏi OTP, không hỏi mật khẩu, luôn fail | 🔴 pam_radius_auth.so đặt trước @include common-auth |
Đọc /etc/pam.d/sshd |
Đảo thứ tự + thêm force_prompt |
| 27 | SSH không hỏi OTP | UsePAM no, hoặc file trong sshd_config.d/ ghi đè |
sshd -T \| grep -i pam |
Sửa cấu hình thực tế |
| 28 | Mất SSH hoàn toàn | Sai cú pháp/thứ tự PAM | Console qua hypervisor | Rollback từ backup; nếu console cũng chặn → GRUB init=/bin/bash |
| 29 | sudo cũng đòi OTP |
Lỡ sửa /etc/pam.d/common-auth |
diff với backup |
Chỉ sửa file sshd |
| 30 | Windows: RDP không bị hỏi OTP | enable_filter chưa bật → Windows dùng System CP |
Registry | enable_filter = "1" |
| 31 | Windows: không CP nào hiện, không login được | Đặt cpus_* = "3e" |
— | Safe Mode → sửa registry |
| 32 | Windows: cấu hình registry không có tác dụng | Sai đường dẫn (thiếu GmbH) |
reg query |
HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP |
| 33 | Windows: sửa login_text không có tác dụng |
Từ v3.7.0 đã thay bằng file locales | — | Sửa file trong C:\ProgramData\Netknights GmbH\...\locales |
| 34 | Windows: login rất chậm | resolve_upn=1 mà DC không tới được; hoặc refill offline khi mất mạng |
C:\PICredentialProviderLog.txt |
Tắt resolve_upn; đặt offline_threshold |
| 35 | Restore xong không ai đăng nhập được | Backup thiếu enckey |
tar tzf backup.tgz \| grep enckey |
Luôn dùng -e |
| 36 | pi-manage backup báo unsupported SQL syntax |
Dùng PostgreSQL | — | Chuyển sang MariaDB |
| 37 | Backup fail âm thầm | Bug Python 3.12 ở bản < 3.13.1 | apt-cache policy privacyidea |
Nâng lên ≥ 3.13.1 |
| 38 | Bảng challenge phình to | Cron challenge cleanup bị tắt/xoá |
mysql pi -e "SELECT COUNT(*) FROM challenge;"; cat /etc/cron.d/privacyidea |
Khôi phục dòng cron; chạy tay pi-manage config challenge cleanup --age 60 |
| 39 | Disk đầy dần | Audit log không rotate | df -h; mysql pi -e "SELECT COUNT(*) FROM pidea_audit;" |
pi-manage audit rotate --age 180 định kỳ |
| 40 | Web app cho vào khi OTP server chết | Fail open | Đọc code | Sửa thành fail closed (HTTP 503) |
10.3. Giám sát và cảnh báo
Cần alert khi:
| Sự kiện | Ngưỡng | Mức |
|---|---|---|
| privacyIDEA không phản hồi | > 2 lần liên tiếp | 🔴 Critical |
| RADIUS không phản hồi | > 2 lần liên tiếp | 🔴 Critical |
| Lệch giờ trên bất kỳ máy nào | > 5 giây | 🔴 Critical |
| Backup thất bại hoặc không có backup trong 26 giờ | 1 lần | 🔴 Critical |
| Tài khoản break-glass được dùng | 1 lần | 🔴 Critical — alert tới điện thoại |
| Tỷ lệ xác thực thất bại | > 20% trong 15 phút | 🟠 Warning — có thể đang bị tấn công, hoặc NTP hỏng |
| Số token bị khoá | > 5 trong 1 giờ | 🟠 Warning |
Truy cập file enckey ngoài process privacyidea |
Mọi lần | 🔴 Critical |
| Token bị xoá/tạo mới | Mọi lần | 🟡 Info — nhưng phải vào audit trail |
| Chứng chỉ TLS sắp hết hạn | < 30 ngày | 🟠 Warning |
| Disk còn < 15% | — | 🟠 Warning |
Giám sát truy cập file enckey bằng auditd:
sudo apt install -y auditd
sudo auditctl -w /etc/privacyidea/enckey -p rwa -k pi_enckey
echo '-w /etc/privacyidea/enckey -p rwa -k pi_enckey' | sudo tee -a /etc/audit/rules.d/privacyidea.rules
sudo systemctl restart auditd
# Xem ai đã đọc file
sudo ausearch -k pi_enckey --start today
Đẩy log về SIEM:
sudo tee /etc/rsyslog.d/50-privacyidea.conf > /dev/null <<'EOF'
# Gửi log privacyIDEA và auth về SIEM
module(load="imfile")
input(type="imfile"
File="/var/log/privacyidea/privacyidea.log"
Tag="privacyidea"
Severity="info")
auth,authpriv.* @@192.168.10.70:514
:syslogtag, contains, "privacyidea" @@192.168.10.70:514
EOF
sudo systemctl restart rsyslog
Health-check tổng hợp cho Nagios/Zabbix/Icinga:
sudo tee /usr/local/bin/check-privacyidea.sh > /dev/null <<'EOF'
#!/bin/bash
# Exit: 0=OK 1=WARNING 2=CRITICAL
ERR=(); WARN=()
# 1. API sống
curl -sf -o /dev/null --max-time 5 https://otp01.lab.local/ || ERR+=("API khong phan hoi")
# 2. Dịch vụ
for s in apache2 mariadb freeradius; do
systemctl is-active --quiet "$s" || ERR+=("$s DUNG")
done
# 3. Thời gian
OFF=$(chronyc tracking 2>/dev/null | awk '/System time/ {print ($4<0)?-$4:$4}')
awk -v a="${OFF:-99}" 'BEGIN{exit !(a>5)}' && ERR+=("Dong ho lech ${OFF}s")
# 4. RADIUS phản hồi (Access-Reject cũng là sống)
echo "User-Name=hc,User-Password=0" | radclient -t 3 -r 1 127.0.0.1:1812 auth testing123 2>&1 \
| grep -qE "Access-(Accept|Reject|Challenge)" || WARN+=("RADIUS khong phan hoi")
# 5. Backup gần đây
find /var/backups/privacyidea -name '*.gpg' -mtime -1.1 | grep -q . || ERR+=("Khong co backup 26h qua")
# 6. Disk
USE=$(df / | awk 'NR==2{print $5}' | tr -d '%')
(( USE > 85 )) && WARN+=("Disk day ${USE}%")
if (( ${#ERR[@]} )); then printf 'CRITICAL: %s\n' "${ERR[*]}"; exit 2; fi
if (( ${#WARN[@]} )); then printf 'WARNING: %s\n' "${WARN[*]}"; exit 1; fi
echo "OK - privacyIDEA hoat dong binh thuong"; exit 0
EOF
sudo chmod +x /usr/local/bin/check-privacyidea.sh
sudo /usr/local/bin/check-privacyidea.sh
10.4. Bảo trì định kỳ
| Việc | Tần suất | Lệnh |
|---|---|---|
| Dọn challenge cũ | Hằng ngày (cron) | pi-manage config challenge cleanup --age 60 — gói đã bật sẵn trong /etc/cron.d/privacyidea |
| Backup | Hằng ngày | /usr/local/bin/backup-privacyidea.sh |
| Rotate audit log | Hằng tháng | pi-manage audit rotate --age 180 |
| Kiểm tra tài khoản break-glass còn dùng được | 3 tháng | Thủ công |
| Cập nhật hệ thống | Hằng tháng | apt update && apt upgrade |
| Nâng cấp privacyIDEA | Khi có bản mới | ⚠️ Đọc READ_BEFORE_UPDATE.md trước |
| Diễn tập DR | 6 tháng | Restore vào VM cách ly, verify bằng token thật |
| Rà soát token không dùng | 6 tháng | privacyidea-get-unused-tokens |
| Rà soát policy và quyền admin | 6 tháng | pi-manage config export -t policy |
| Kiểm tra hạn chứng chỉ TLS | Hằng tháng | openssl x509 -enddate -noout -in /etc/ssl/certs/privacyideaserver.pem |
Quy trình nâng cấp an toàn:
# 1. Backup TRƯỚC
sudo /usr/local/bin/backup-privacyidea.sh
# 2. Snapshot VM
# 3. Đọc READ_BEFORE_UPDATE.md của TẤT CẢ các version bạn nhảy qua
# https://github.com/privacyidea/privacyidea/blob/master/READ_BEFORE_UPDATE.md
# 4. Nâng cấp (postinst tự chạy privacyidea-schema-upgrade)
sudo apt update && sudo apt install --only-upgrade privacyidea privacyidea-apache2
# 5. Kiểm tra
sudo /usr/local/bin/pi-diag.sh
sudo /usr/local/bin/check-privacyidea.sh
# 6. Test bằng token thật
⚠️ Breaking change đã biết của privacyIDEA 3.14 (bản kế tiếp):
- * trở thành wildcard duy nhất trong search — % và _ giờ khớp literal. Script cũ dùng serial="OATH%" phải đổi thành serial="OATH*"
- Token u2f bị xoá
- /validate/samlcheck bị xoá — dùng /validate/check + policy add_user_in_response
- rollout_state của token đã enroll đổi từ "" sang "enrolled"
- Migration customuserattribute → internaluserattribute yêu cầu dừng service khi chạy schema upgrade
- WebUI 2.0 trở thành mặc định
Đừng bật auto-upgrade cho gói privacyIDEA.
10.5. Lộ trình rollout theo giai đoạn
Đừng bật MFA cho cả tổ chức cùng lúc. Đây là công thức chắc chắn dẫn tới sự cố.
| GĐ | Phạm vi | Chế độ | Thời lượng | Tiêu chí chuyển tiếp |
|---|---|---|---|---|
| 0. Lab | Môi trường test | Bắt buộc | 1–2 tuần | Mọi test pass, đã diễn tập DR |
| 1. Pilot | Đội IT (3–5 người) | Bắt buộc | 2 tuần | Không sự cố nào IT không tự xử lý được |
| 2. Early adopter | 20–30 người tình nguyện | Tuỳ chọn → Bắt buộc | 2 tuần | Tỷ lệ ticket < 10%; tài liệu user hoàn thiện |
| 3. Theo phòng ban | Mỗi tuần một phòng | Tuỳ chọn 1 tuần → Bắt buộc | 4–8 tuần | Helpdesk chịu tải được |
| 4. Toàn tổ chức | Tất cả | Bắt buộc | — | — |
| 5. Siết chặt | Bỏ miễn trừ tạm thời, nâng admin lên FIDO2 | Bắt buộc | — | — |
Chuẩn bị trước khi bắt đầu GĐ1:
- Tài liệu hướng dẫn user (1 trang, có ảnh chụp màn hình, tiếng Việt)
- Video hướng dẫn cài app OTP (2–3 phút)
- Helpdesk đã được đào tạo, có quy trình reset token kèm bước xác minh danh tính
- Kênh hỗ trợ riêng cho giai đoạn rollout
- Email thông báo trước 1 tuần, nhắc lại trước 1 ngày
- Tài khoản break-glass đã test và niêm phong
- Monitoring + alert đã chạy và đã test bằng cách gây lỗi giả
- Backup đã chạy và đã restore thử thành công ít nhất một lần
Kiểm soát phạm vi bằng group AD:
PS> New-ADGroup -Name "MFA-Wave1" -GroupScope Global -GroupCategory Security
PS> Add-ADGroupMember -Group "MFA-Wave1" -Members it-admin1,it-admin2
# Dùng Security Filtering của GPO theo group này
10.6. Hướng mở rộng
| Giai đoạn | Việc | Giá trị |
|---|---|---|
| 1–3 tháng | Dựng OTP02 thành standby thật; monitoring đầy đủ; diễn tập DR lần đầu | RTO từ 2 giờ xuống 5 phút |
| Mở rộng OTP sang VPN (OpenVPN/WireGuard/RRAS qua RADIUS) | Bảo vệ đường vào từ xa — nơi rủi ro nhất | |
| 3–6 tháng | Chuyển sang MariaDB Galera cho HA active-active thật | HA đúng nghĩa |
| OTP cho thiết bị mạng (SSH vào switch/router/firewall qua RADIUS) | Bảo vệ hạ tầng mạng | |
| Đẩy audit log vào SIEM, xây use case phát hiện tấn công | Phát hiện sớm | |
| Bật token PUSH (người dùng chỉ cần bấm Approve, không gõ mã) | UX tốt hơn nhiều | |
| 6–12 tháng | ⭐ FIDO2/WebAuthn cho tài khoản đặc quyền | Chống được real-time phishing — thứ TOTP không làm được |
| Passkey cho web app nội bộ | Passwordless | |
| SSO tập trung (Keycloak dùng privacyIDEA làm nguồn OTP) | Một lần đăng nhập cho mọi app |
⭐ Về FIDO2 — lời khuyên thẳng thắn: TOTP là bước nhảy vọt so với chỉ-mật-khẩu, và tài liệu này giúp bạn triển khai nó đúng cách. Nhưng TOTP vẫn phishable: kẻ tấn công dựng một trang đăng nhập giả, người dùng nhập cả mật khẩu lẫn OTP vào đó, và kẻ tấn công dùng ngay trong 30 giây. Công cụ làm việc này (Evilginx, Modlishka) là mã nguồn mở và dễ dùng.
FIDO2/WebAuthn chặn được điều đó về mặt giao thức, không phải bằng cách huấn luyện người dùng: khoá bảo mật ký với origin của trang web, nên trang giả mạo không lấy được chữ ký hợp lệ.
privacyIDEA hỗ trợ FIDO2 ngay trong hệ thống bạn vừa dựng. Với nhóm admin (10–20 người), chi phí là vài trăm đô cho YubiKey — rẻ hơn rất nhiều so với một sự cố ransomware. Hãy bắt đầu thử nghiệm ngay bây giờ, song song với TOTP.
10.7. Checklist bàn giao
Hạ tầng
- [ ] OTP01 chạy privacyIDEA ≥ 3.13.1 trên Ubuntu 24.04 (máy chuyên dụng)
- [ ] chrony đồng bộ, lệch < 0,1 giây, có cron giám sát độ lệch
- [ ] Time sync của hypervisor đã tắt
- [ ] Firewall mở đúng 4 port, đóng phần còn lại
- [ ] Chứng chỉ TLS từ CA nội bộ, SAN có cả DNS lẫn IP
- [ ] CA nội bộ đã push vào Trusted Root của mọi client
- [ ] PI_BASE_URL, PI_FORCE_HTTPS, PI_ENABLE_CSP đã đặt
- [ ] Cron challenge cleanup đã bật
User và token
- [ ] Resolver AD dùng LDAPS, UIDTYPE=objectGUID, NOREFERRALS=1
- [ ] Resolver nội bộ đã tạo (nếu cần)
- [ ] Realm lab là mặc định, gộp đủ resolver
- [ ] otppin đã quyết định và cấu hình
- [ ] verify_enrollment đã bật
- [ ] max_active_token_per_user=2
- [ ] Max failcount = 6, timeWindow phù hợp
- [ ] fail2ban chạy và regex đã đối chiếu với log thật
RADIUS
- [ ] 7 Perl module đã cài (nhớ liburi-encode-perl)
- [ ] Module ở /usr/share/privacyidea/freeradius/, ini ở /etc/privacyidea/
- [ ] SSL_CHECK = true (đúng chữ thường)
- [ ] Client khai riêng từng thiết bị, secret ≥ 24 ký tự, require_message_authenticator = yes
- [ ] Client localhost mặc định đã xoá hoặc đổi secret
- [ ] freeradius -X in Config File ... found!
Windows
- [ ] Credential Provider 3.8 cài trên máy đích
- [ ] Registry đúng HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP
- [ ] enable_filter = 1
- [ ] cpus_logon đúng giá trị (1d để bắt đầu)
- [ ] fallback_hostname trỏ OTP02
- [ ] excluded_account đúng định dạng và đã test
- [ ] Test RDP: có OTP ✅ / mã sai bị từ chối ✅ / replay bị từ chối ✅
- [ ] Có đường vào console qua hypervisor
Linux
- [ ] radtest ra Access-Accept từ mọi máy Linux
- [ ] /etc/pam.d/sshd: common-auth trước, pam_radius force_prompt sau
- [ ] Failover sang OTP02 đã test
- [ ] Backup /etc/pam.d/sshd và sshd_config đã lưu
- [ ] Automation dùng key, không bị OTP chặn, authorized_keys đã siết
Web
- [ ] Code đọc result.value, không đọc result.status
- [ ] Không retry POST xác thực
- [ ] verify TLS bật với CA nội bộ
- [ ] Fail closed khi OTP server chết
- [ ] Rate limiting ở nginx + policy giới hạn IP + API key
- [ ] Session chỉ cấp sau khi qua yếu tố 2, có session.clear()
Backup / DR
- [ ] Backup tự động hằng ngày, có cờ -e, script tự kiểm tra có enckey
- [ ] Mã hoá bằng GPG public key, private key cất ở nơi khác, ≥ 2 người biết
- [ ] Nhân bản ra ≥ 2 nơi, có checksum
- [ ] Giám sát tuổi backup
- [ ] ⭐ Đã restore thử thành công vào VM cách ly ít nhất một lần
- [ ] Runbook DR viết ra, có trong wiki, và in ra để trong tủ server
- [ ] Lịch diễn tập DR 6 tháng/lần đã vào calendar
Vận hành - [ ] Quy trình reset token có bước xác minh danh tính - [ ] Helpdesk đã đào tạo - [ ] Tài liệu hướng dẫn user tiếng Việt, có ảnh minh hoạ - [ ] Alert đã cấu hình và đã test bằng cách gây lỗi giả - [ ] Log đã đẩy về SIEM - [ ] Break-glass: đã tạo, đã test, có alert, ≥ 2 người biết - [ ] Có owner chịu trách nhiệm chính cho hệ thống OTP
PHẦN 11. PROXMOX VE, VMWARE VÀ ỨNG DỤNG WEB BÊN THỨ BA
Câu hỏi hay gặp nhất sau khi dựng xong OTP server: "Cái này gắn được vào Proxmox, vào vCenter, vào mấy con web app của hãng khác không?"
Câu trả lời ngắn: được — nhưng không phải cái nào cũng cùng một đường, và có đúng một trường hợp thực sự không làm được.
11.1. Trả lời nhanh trước khi đọc chi tiết
| Hệ thống | Có gắn được privacyIDEA không | Đường đi |
|---|---|---|
| Proxmox VE 8.x / 9.x (web UI) | ✅ Được | PAM realm + pam_radius — hoặc realm OIDC qua Keycloak. Xem 11.3 |
| VMware vCenter 7 / 8 | 🟡 Được, nhưng gián tiếp | Identity Federation → AD FS (có plugin privacyIDEA). vCenter không nói được RADIUS. Xem 11.4 |
| ESXi host (Host Client, SSH, DCUI) | ❌ Không | ESXi không có cơ chế MFA. Bọc bằng lockdown mode + bastion. Xem 11.4.3 |
| VMware Horizon | ✅ Rất dễ | Horizon nói RADIUS nguyên bản — trỏ thẳng vào FreeRADIUS ở Phần 5 |
| App web nói RADIUS (Fortigate, pfSense, Grafana Enterprise, nhiều NAS) | ✅ Được | Thêm client vào clients.conf, xong. Xem 11.5.1 |
| App web nói LDAP | ✅ Được | privacyIDEA LDAP-Proxy. Xem 11.5.2 |
| App web nói SAML / OIDC | ✅ Được, đẹp nhất | Keycloak + keycloak-provider. Xem 11.5.3 |
| App web không hỗ trợ gì cả | ✅ Được | Chặn bằng reverse proxy phía trước. Xem 11.5.5 |
11.2. Nguyên tắc: đi theo thứ mà ứng dụng đã biết nói
Đừng hỏi "privacyIDEA hỗ trợ Proxmox chưa". Hãy hỏi ngược lại: ứng dụng này biết nói giao thức xác thực nào? privacyIDEA nói được gần hết, nên nút thắt luôn nằm ở phía ứng dụng.
🔴 Cái bẫy lớn nhất của mọi tích hợp qua giao diện web: không có challenge-response.
Ở Phần 5 và Phần 7, RADIUS trả Access-Challenge để hỏi mã OTP ở vòng thứ hai — SSH làm được vì nó hỏi tiếp được. Form đăng nhập web chỉ gửi đúng một lần: một ô user, một ô password, bấm Login là hết. Không có vòng hai.
Vì vậy với mọi tích hợp web, bạn phải chuyển sang mô hình nối chuỗi: người dùng gõ liền <mật khẩu><OTP 6 số> vào ô password. Chính sách quyết định điều này là otppin (xem 4.3):
otppin |
Người dùng gõ vào ô password |
|---|---|
userstore |
MatKhauAD + 123456 → MatKhauAD123456 |
tokenpin |
PIN + 123456 → PIN123456 |
none |
chỉ 123456 (app tự kiểm tra mật khẩu riêng) |
Hãy quyết định otppin trước khi hướng dẫn người dùng, và viết đúng một câu hướng dẫn duy nhất cho họ. Đây là nguyên nhân số 1 của "tôi nhập đúng mà nó báo sai".
11.3. Proxmox VE
11.3.1. Proxmox có sẵn những gì
Cần biết rõ trước khi chọn đường:
| Thành phần | Proxmox VE hỗ trợ |
|---|---|
| Realm xác thực | Linux PAM · Proxmox VE authentication server · LDAP · Microsoft AD · OpenID Connect (từ PVE 7.0) |
| RADIUS realm | ❌ Không có. Đây là lý do phải đi vòng qua PAM hoặc OIDC |
| TFA tích hợp sẵn | TOTP · WebAuthn · Recovery key · Yubico OTP (chỉ định được URL validation server) |
Ba đường khả thi, xếp theo mức khuyến nghị:
11.3.2. Cách A — PAM realm + pam_radius (nhanh nhất, ~20 phút)
Realm "Linux PAM standard authentication" của Proxmox xác thực qua PAM service common-auth. Chèn pam_radius vào đó là web UI của Proxmox sẽ hỏi privacyIDEA.
# Trên node Proxmox
apt update && apt install -y libpam-radius-auth
nano /etc/pam_radius_auth.conf
# server[:port] shared_secret timeout(giây)
192.168.10.10:1812 MatKhauRadiusRatManh 5
chmod 600 /etc/pam_radius_auth.conf
Thêm OTP01 vào danh sách client trên FreeRADIUS (xem 5.4) với đúng secret trên, rồi sửa PAM:
cp /etc/pam.d/common-auth /root/common-auth.bak-$(date +%F)
nano /etc/pam.d/common-auth
Đặt dòng đầu tiên, trước pam_unix.so:
auth [success=done new_authtok_reqd=done default=ignore] pam_radius_auth.so
auth requisite pam_deny.so
auth required pam_permit.so
# ... phần cũ giữ nguyên phía dưới
🔴 Bốn cảnh báo bắt buộc đọc trước khi lưu file:
/etc/pam.d/common-authdùng chung cho cả SSH của node Proxmox. Sửa sai là mất luôn cả web UI lẫn SSH.- Mở sẵn một phiên SSH thứ hai đang đăng nhập trước khi sửa. Nếu hỏng, bạn còn đường vào để rollback.
- Giữ realm
pve(Proxmox VE authentication server) với một tài khoản admin không đi qua PAM làm đường thoát hiểm. Đây là break-glass account của bạn. - Người dùng gõ
<mật khẩu Linux/AD><OTP>liền nhau vào ô password (xem cảnh báo ở 11.2), không có ô riêng cho OTP.
Test — luôn test từ máy khác, giữ nguyên phiên hiện tại:
# Trên OTP01: xem RADIUS có nhận request không
tail -f /var/log/freeradius/radius.log
# Trên máy khác: đăng nhập web Proxmox, realm "Linux PAM standard authentication"
# hoặc test bằng radtest cho nhanh:
radtest hoangnv 'MatKhauAD123456' 192.168.10.10 0 MatKhauRadiusRatManh
11.3.3. Cách B — Realm OpenID Connect qua Keycloak (đẹp nhất về lâu dài)
Proxmox nói OIDC nguyên bản, và privacyIDEA có keycloak-provider chính thức. Chuỗi:
Proxmox VE ──OIDC──► Keycloak ──REST──► privacyIDEA
realm oidc + privacyIDEA OTP01
provider (2FA)
Các bước lớn:
- Dựng Keycloak (container hoặc VM riêng), tạo realm và client cho Proxmox.
- Cài
keycloak-providercủa privacyIDEA vào Keycloak, trỏ vềhttps://otp01.lab.local. - Sửa authentication flow của Keycloak: sau bước password, thêm bước privacyIDEA OTP.
- Trên Proxmox: Datacenter → Permissions → Realms → Add → OpenID Connect Server, điền Issuer URL, Client ID, Client Key, bật Autocreate Users nếu muốn.
Ưu điểm: có ô nhập OTP riêng (không phải nối chuỗi), SSO dùng lại được cho Grafana / GitLab / Nextcloud, và không đụng vào PAM của node. Nhược điểm: thêm một thành phần phải vận hành và backup (Keycloak). Chỉ đáng làm khi bạn có từ ~3 ứng dụng trở lên cần SSO.
11.3.4. Cách C — Yubico OTP trỏ thẳng về privacyIDEA
Ít người biết: TFA kiểu Yubico OTP của Proxmox cho phép chỉ định URL của validation server. privacyIDEA có sẵn endpoint theo Yubico Validation Protocol V2.0 tại /ttype/yubikey, và có thể expose thêm đường /wsapi/2.0/verify cho đúng chuẩn Yubico:
# Trên OTP01 — bật rewrite trong vhost privacyIDEA
sudo nano /etc/apache2/sites-available/privacyidea.conf
RewriteEngine on
RewriteRule "^/wsapi/2.0/verify" "/ttype/yubikey" [PT]
sudo apache2ctl configtest && sudo systemctl reload apache2
Sau đó trong Proxmox: Datacenter → Options → TFA → Yubico, điền API URL trỏ về https://otp01.lab.local/wsapi/2.0/verify.
📌 Cách này chỉ áp dụng cho token Yubikey chạy chế độ AES (Yubico OTP) — không dùng được cho TOTP trên điện thoại. Nếu tổ chức bạn phát Yubikey thì đây là đường sạch nhất cho Proxmox; nếu dùng app điện thoại thì bỏ qua mục này.
11.3.5. Chọn cách nào
| Cách A — PAM+RADIUS | Cách B — OIDC+Keycloak | Cách C — Yubico OTP | |
|---|---|---|---|
| Thời gian dựng | ~20 phút | 3–5 giờ | ~30 phút |
| Ô nhập OTP riêng | ❌ nối chuỗi vào password | ✅ có | ✅ có |
| Loại token | mọi loại | mọi loại | chỉ Yubikey AES |
| Rủi ro khi sai | 🔴 mất cả SSH của node | 🟡 chỉ hỏng realm OIDC | 🟢 thấp |
| Dùng lại cho app khác | qua RADIUS | ⭐ SSO cho mọi app | không |
| Nên chọn khi | 1–3 node, muốn xong nhanh | có nhiều app cần SSO | tổ chức đã phát Yubikey |
11.4. VMware
11.4.1. Sự thật cần biết trước: vCenter không nói RADIUS
Đây là điểm hay bị nhầm nhất. vCenter Single Sign-On hỗ trợ:
| Cơ chế | vCenter hỗ trợ |
|---|---|
| RADIUS | ❌ Không. SSO chỉ hỗ trợ RSA SecurID native, không phải RADIUS |
| RSA SecurID | ✅ (cấu hình bằng sso-config, không làm được từ vSphere Client) |
| Smart card / CAC | ✅ |
| Identity Federation (OIDC) | ✅ — và đây chính là đường của chúng ta |
Danh sách Identity Provider được hỗ trợ chính thức, theo phiên bản:
| IdP | Từ phiên bản |
|---|---|
| AD FS | vSphere 7.0 |
| Okta | vSphere 8.0 Update 1 |
| Microsoft Entra ID | vSphere 8.0 Update 2 |
| PingFederate | vSphere 8.0 Update 3 |
⚠️ Không có "generic OIDC". Bạn không cắm thẳng Keycloak vào vCenter được, dù Keycloak nói OIDC chuẩn. vCenter chỉ nhận đúng bốn IdP trên, và chỉ một IdP tại một thời điểm (cộng với vsphere.local).
11.4.2. Đường đi thực tế cho môi trường on-premise
vCenter 7/8 ──Identity Federation (OIDC)──► AD FS ──► privacyIDEA
(Windows) OTP01
+ plugin MFA
Các bước lớn:
- Dựng AD FS trên Windows Server (yêu cầu có AD — nếu bạn theo kịch bản không AD ở 3.4 thì đường này không dùng được).
- Cài plugin xác thực privacyIDEA cho AD FS, trỏ về
https://otp01.lab.local. - Trong AD FS, bật plugin làm Additional Authentication Method cho relying party của vCenter.
- Trên vCenter: Administration → Single Sign On → Configuration → Identity Provider → Change Provider → Microsoft ADFS, điền Client ID / Secret / OpenID Address.
📌 Plugin AD FS do NetKnights phát hành. Kiểm tra điều kiện license/subscription của bản bạn tải trước khi đưa vào production — khác với phần lõi privacyIDEA (AGPL), một số plugin phía client đi kèm điều kiện thương mại.
11.4.3. ESXi host: không có MFA, và không nên cố
ESXi (Host Client, SSH, DCUI) không có cơ chế MFA nào. Đừng tìm plugin — không tồn tại. Cách xử lý đúng là giảm bề mặt thay vì thêm lớp xác thực:
| Việc | Lý do |
|---|---|
| Bật Lockdown Mode (Normal hoặc Strict) | Chặn đăng nhập trực tiếp, ép mọi thao tác đi qua vCenter — nơi có MFA |
| Tắt SSH, chỉ bật khi cần và tự tắt lại | Bề mặt tấn công lớn nhất của ESXi |
| Đặt management network trong VLAN riêng, không định tuyến ra ngoài | ESXi không nên với tới được từ mạng người dùng |
| Truy cập qua bastion/jump host có OTP (Phần 7) | MFA nằm ở lớp bastion, không nằm ở ESXi |
Tài khoản root của ESXi: mật khẩu dài, lưu trong PAM vault, chỉ dùng khi khẩn cấp |
Đây là break-glass, không phải tài khoản vận hành |
Nói thẳng: MFA cho ESXi = MFA cho đường vào ESXi, không phải MFA trên chính ESXi.
11.4.4. VMware Horizon — trường hợp dễ nhất
Horizon Connection Server hỗ trợ RADIUS nguyên bản. Vào Connection Server → Authentication → Advanced Authentication → 2-factor authentication → RADIUS, trỏ về 192.168.10.10:1812, dùng secret đã đặt ở Phần 5. Horizon có xử lý được challenge-response, nên ở đây bạn có ô nhập OTP riêng.
11.5. Ứng dụng web bên thứ ba
11.5.1. App nói RADIUS — không có gì phải làm thêm
Fortigate, pfSense, OPNsense, Cisco ASA/ISE, nhiều NAS (Synology, QNAP), Grafana Enterprise, Zabbix, GLPI... Chỉ cần thêm client vào FreeRADIUS:
sudo nano /etc/freeradius/3.0/clients.conf
client fw-hq {
ipaddr = 192.168.10.60
secret = MotSecretRatDaiVaNgauNhien
require_message_authenticator = auto
nas_type = other
}
sudo systemctl restart freeradius
Kiểm tra thiết bị đó có xử lý Access-Challenge không: nếu có thì người dùng có ô nhập OTP riêng; nếu không thì quay lại quy tắc nối chuỗi ở 11.2.
11.5.2. App nói LDAP — dùng privacyIDEA LDAP-Proxy
Nhiều app cũ chỉ biết bind LDAP. privacyIDEA có LDAP-Proxy: nó đứng giữa, nhận bind request, tách phần OTP ra kiểm tra với privacyIDEA, phần mật khẩu chuyển tiếp về LDAP/AD thật.
App ──LDAP bind (mật khẩu+OTP)──► privacyIDEA LDAP-Proxy ──► AD thật
│
└──REST──► privacyIDEA
Dùng khi: GitLab bản cũ, Jira/Confluence Server, NAS, các app nội bộ viết từ lâu. Lưu ý proxy này chạy như một service riêng — nhớ đưa vào giám sát và backup cấu hình.
11.5.3. App nói SAML / OIDC — Keycloak đứng giữa
Đây là cách sạch nhất cho môi trường nhiều ứng dụng. privacyIDEA có plugin cho Keycloak, simpleSAMLphp, Shibboleth, Gluu và AD FS.
Grafana · GitLab · Nextcloud · Proxmox (OIDC) · Jenkins
└──────── SAML/OIDC ────────► Keycloak ──► privacyIDEA
Làm một lần, dùng cho tất cả. Đổi lại bạn phải vận hành thêm Keycloak — nó trở thành thành phần critical ngang privacyIDEA, nên phải backup và HA tương ứng.
11.5.4. App đã có plugin sẵn
Không cần trung gian, cài thẳng plugin của privacyIDEA:
| Ứng dụng | Ghi chú |
|---|---|
| Nextcloud / ownCloud | plugin phổ biến nhất, có ô OTP riêng |
| WordPress | bảo vệ wp-admin |
| Dokuwiki · TYPO3 · OTRS | plugin cộng đồng |
| Django · Flask | dùng thẳng REST API, xem Phần 8 |
Apache2 (mod_authn_radius) |
bảo vệ bất kỳ thư mục nào bằng Basic auth |
11.5.5. App không hỗ trợ gì — chặn bằng reverse proxy
Ứng dụng nội bộ tự viết, dashboard cũ, giao diện quản trị thiết bị... Đặt một lớp xác thực phía trước nó.
Cách đơn giản nhất — Apache + mod_auth_radius:
sudo apt install -y libapache2-mod-auth-radius
sudo a2enmod auth_radius
<VirtualHost *:443>
ServerName dashboard.lab.local
AddRadiusAuth 192.168.10.10:1812 MotSecretRatDaiVaNgauNhien 5:3
AddRadiusCookieValid 60
<Location />
AuthType Basic
AuthName "Dang nhap: mat khau + ma OTP"
AuthBasicProvider radius
Require valid-user
</Location>
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
Với nginx: dùng auth_request trỏ tới một endpoint kiểm tra nội bộ (viết bằng thư viện client ở Phần 8), hoặc dùng plugin nginx (lua) của privacyIDEA. nginx không có module RADIUS dựng sẵn như Apache, nên đường này tốn công hơn.
🔴 Bịt đường đi tắt. Đặt reverse proxy phía trước chỉ có tác dụng nếu ứng dụng gốc không còn nghe trực tiếp trên mạng. Bắt buộc:
ss -tlnp | grep 8080 # phải là 127.0.0.1:8080, KHÔNG phải 0.0.0.0:8080
Nếu app vẫn nghe 0.0.0.0, người dùng chỉ cần gõ http://ip:8080 là đi vòng qua toàn bộ lớp xác thực bạn vừa dựng.
11.5.6. Bảng tra nhanh
| Ứng dụng | Đường | Có ô OTP riêng |
|---|---|---|
| Proxmox VE | PAM+RADIUS · hoặc OIDC+Keycloak | ❌ / ✅ |
| VMware vCenter 7/8 | Identity Federation → AD FS + plugin | ✅ |
| VMware ESXi | ❌ không có — bọc bằng lockdown + bastion | — |
| VMware Horizon | RADIUS trực tiếp | ✅ |
| Fortigate · pfSense · Cisco | RADIUS trực tiếp | ✅ (đa số) |
| Synology · QNAP | RADIUS trực tiếp | tuỳ model |
| Grafana · GitLab · Jenkins | OIDC/SAML qua Keycloak | ✅ |
| Nextcloud · WordPress | plugin privacyIDEA | ✅ |
| Jira/Confluence Server · app cũ | LDAP-Proxy | ❌ nối chuỗi |
| Dashboard nội bộ tự viết | reverse proxy (Apache mod_auth_radius) | ❌ nối chuỗi |
| Zimbra · Roundcube | RADIUS hoặc plugin tuỳ bản | tuỳ |
11.6. Bốn cái bẫy chung của mọi tích hợp
| Bẫy | Hậu quả | Cách tránh |
|---|---|---|
| Không có đường thoát hiểm | privacyIDEA chết → không ai vào được hệ thống nào, kể cả admin | Mỗi hệ thống giữ một tài khoản break-glass không đi qua MFA, mật khẩu dài lưu trong két, có giám sát khi bị dùng |
| Fail-open âm thầm | OTP server chết mà app vẫn cho đăng nhập bằng mật khẩu → MFA thành hình thức | Kiểm tra bằng cách tắt Apache trên OTP01 rồi thử đăng nhập từng hệ thống. Ghi lại hệ thống nào fail-open và quyết định có chấp nhận không |
| Nối chuỗi mà không báo người dùng | Hàng loạt ticket "tôi nhập đúng mà báo sai" | Viết đúng một câu hướng dẫn cho mỗi hệ thống, kèm ảnh màn hình, gửi trước ngày bật |
| Mỗi app một nguồn sự thật | Không audit được, không thu hồi token tập trung được | Mọi đường phải quy về /validate/check của privacyIDEA. Không app nào được giữ seed riêng |
⭐ Kiểm tra cuối cùng trước khi bật MFA diện rộng — làm đủ 4 việc này cho từng hệ thống vừa tích hợp:
- Đăng nhập thành công bằng mật khẩu + OTP đúng.
- Đăng nhập thất bại khi OTP sai — và log ghi lại được ở audit của privacyIDEA.
- Dùng lại mã vừa dùng (replay) → phải bị từ chối.
- Tắt Apache trên OTP01 → xác nhận hệ thống hành xử đúng như bạn mong đợi (fail-closed hay fail-open), rồi bật lại.
PHỤ LỤC A. LỆNH THAM CHIẾU NHANH
# ══ Quản trị viên ══════════════════════════════════════════════
sudo pi-manage admin add <user> -e <email>
sudo pi-manage admin list
sudo pi-manage admin change <user> --password <PW>
sudo pi-manage admin delete <user>
# ══ Resolver ═══════════════════════════════════════════════════
sudo pi-manage config resolver list
sudo pi-manage config resolver create <ten> ldapresolver <file.py>
sudo pi-manage config resolver create_internal <ten>
# Loại: ldapresolver | sqlresolver | httpresolver | passwdresolver | scimresolver
# ══ Realm ══════════════════════════════════════════════════════
sudo pi-manage config realm list # dấu '*' = mặc định
sudo pi-manage config realm create <realm> <res1> [<res2>...]
sudo pi-manage config realm set_default <realm>
sudo pi-manage config realm delete <realm>
# ══ Policy ═════════════════════════════════════════════════════
sudo pi-manage config policy list
sudo pi-manage config policy create <ten> <scope> "<action>"
sudo pi-manage config policy enable|disable|delete <ten>
# Scope: authentication authorization admin user enrollment webui
# audit token container register hardening
# ══ Setup / khoá ═══════════════════════════════════════════════
sudo pi-manage setup create_enckey
sudo pi-manage setup create_audit_keys [-k 2048]
sudo pi-manage setup create_tables
sudo pi-manage setup encrypt_enckey /etc/privacyidea/enckey
# ══ Backup ═════════════════════════════════════════════════════
sudo pi-manage backup create -e # ⭐ -e = KÈM enckey
sudo pi-manage backup create -e -d <dir> -c /etc/privacyidea/ -r /etc/freeradius/3.0
sudo pi-manage backup restore <file.tgz> [--keep-db-uri]
# ══ Audit ══════════════════════════════════════════════════════
sudo pi-manage audit rotate --age 180
sudo pi-manage audit dump
# ══ Export / import cấu hình ═══════════════════════════════════
sudo pi-manage config export -f yaml -o cfg.yaml --censor
sudo pi-manage config import -i cfg.yaml [--skip-invalid]
# ══ Bảo trì ════════════════════════════════════════════════════
sudo pi-manage config challenge cleanup --age 60
sudo pi-manage api createtoken -r validate -u <ten_dinh_danh> # -u BAT BUOC
sudo privacyidea-get-unused-tokens
sudo privacyidea-token-janitor
# ══ Dịch vụ ════════════════════════════════════════════════════
sudo systemctl restart apache2 mariadb freeradius
sudo freeradius -X # debug foreground
radtest <user> <pass+otp> <server>:1812 0 <secret>
PHỤ LỤC B. REST API THAM CHIẾU
export PI_URL="https://otp01.lab.local"
# ── Lấy JWT ────────────────────────────────────────────────────
export PI_TOKEN=$(curl -sk -X POST $PI_URL/auth \
-d "username=admin" -d "password=<pw>" | jq -r '.result.value.token')
# ⚠️ Header: PI-Authorization: <JWT> — JWT THÔ, KHÔNG có "Bearer"
# ── Xác thực (không cần JWT) ───────────────────────────────────
curl -sk -X POST $PI_URL/validate/check \
-d "user=hoangnv" -d "realm=lab" -d "pass=<matkhau><OTP>"
# ⭐ Đọc result.value, KHÔNG đọc result.status
# ── Xác thực kiểu RADIUS (204=OK, 400=fail, body rỗng) ─────────
curl -sk -o /dev/null -w "%{http_code}\n" -X POST $PI_URL/validate/radiuscheck \
-d "user=hoangnv" -d "realm=lab" -d "pass=<matkhau><OTP>"
# ── Enroll token TOTP ──────────────────────────────────────────
curl -sk -X POST $PI_URL/token/init -H "PI-Authorization: $PI_TOKEN" \
-d "type=totp" -d "user=hoangnv" -d "realm=lab" -d "genkey=1" \
-d "otplen=6" -d "hashlib=sha1" -d "timeStep=30"
# ── Liệt kê token của user ─────────────────────────────────────
curl -sk -G $PI_URL/token/ -H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "user=hoangnv" --data-urlencode "realm=lab" | jq '.result.value.tokens'
# ── Vô hiệu hoá / bật / xoá / reset failcount ──────────────────
curl -sk -X POST $PI_URL/token/disable -H "PI-Authorization: $PI_TOKEN" -d "serial=TOTP0001"
curl -sk -X POST $PI_URL/token/enable -H "PI-Authorization: $PI_TOKEN" -d "serial=TOTP0001"
curl -sk -X POST $PI_URL/token/reset -H "PI-Authorization: $PI_TOKEN" -d "serial=TOTP0001"
curl -sk -X DELETE $PI_URL/token/TOTP0001 -H "PI-Authorization: $PI_TOKEN"
# ── Quản lý user (resolver editable) ───────────────────────────
curl -sk -G $PI_URL/user/ -H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "realm=lab" --data-urlencode "username=hoangnv"
curl -sk -X POST $PI_URL/user/ -H "PI-Authorization: $PI_TOKEN" \
-d "user=nhathau01" -d "resolver=local_users" -d "[email protected]" -d "password=..."
# ── Audit log ──────────────────────────────────────────────────
curl -sk -G $PI_URL/audit/ -H "PI-Authorization: $PI_TOKEN" \
--data-urlencode "page_size=20" --data-urlencode "user=hoangnv" \
| jq '.result.value.auditdata[] | {date, action, success, user, serial, client}'
# Cột IP nguồn tên là "client", không phải "client_ip"
PHỤ LỤC C. FILE VÀ ĐƯỜNG DẪN QUAN TRỌNG
| Hệ thống | Đường dẫn | Mục đích |
|---|---|---|
| privacyIDEA | /etc/privacyidea/enckey |
🔴🔴 Khoá mã hoá seed — quan trọng nhất |
| privacyIDEA | /etc/privacyidea/pi.cfg |
🔴 Cấu hình chính (SECRET_KEY, PI_PEPPER, DB URI) |
| privacyIDEA | /etc/privacyidea/private.pem, public.pem |
🟠 Khoá ký audit log |
| privacyIDEA | /opt/privacyidea/ |
Virtualenv chứa code |
| privacyIDEA | /var/log/privacyidea/privacyidea.log |
Log ứng dụng |
| privacyIDEA | /var/lib/privacyidea/backup/ |
Nơi pi-manage backup ghi |
| Apache | /etc/apache2/sites-available/privacyidea.conf |
vhost :443 (⚠️ đừng thêm %q vào LogFormat) |
| TLS | /etc/ssl/certs/privacyideaserver.pem + /etc/ssl/private/...key |
Chứng chỉ server |
| FreeRADIUS | /usr/share/privacyidea/freeradius/privacyidea_radius.pm |
Module Perl |
| FreeRADIUS | /etc/privacyidea/rlm_perl.ini |
🔴 Cấu hình plugin (chmod 640 root:freerad) |
| FreeRADIUS | /etc/freeradius/3.0/mods-available/privacyidea |
Instance module |
| FreeRADIUS | /etc/freeradius/3.0/clients.conf |
🔴 Shared secret của mọi client |
| FreeRADIUS | /etc/freeradius/3.0/sites-available/default |
Virtual server |
| Linux client | /etc/pam.d/sshd |
🔴 PAM stack (thứ tự module quan trọng) |
| Linux client | /etc/pam_radius_auth.conf |
🔴 Cấu hình RADIUS client (chmod 600) |
| Linux client | /etc/ssh/sshd_config + /etc/ssh/sshd_config.d/ |
Cấu hình OpenSSH |
| Linux client | /etc/privacyidea/pam.sqlite |
Cache offline OTP (Phương án B) |
| Windows CP | HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP |
Cấu hình (có dấu cách và GmbH) |
| Windows CP | C:\PICredentialProviderLog.txt |
Log debug |
| Windows CP | C:\ProgramData\Netknights GmbH\PrivacyIDEA Credential Provider\locales |
File dịch (thay cho login_text từ v3.7) |
| Windows CP | C:\offlineFile.json |
Cache offline OTP |
| Web app | /etc/otpapp/secrets.env |
🔴 Secret của app |
PHỤ LỤC D. GIÁ TRỊ DỄ NHẦM — BẢNG TRA NHANH
| Thứ | Giá trị ĐÚNG | Giá trị SAI hay gặp |
|---|---|---|
| Header xác thực API | PI-Authorization: <jwt> |
~~Authorization: Bearer <jwt>~~ |
| Kết quả xác thực | result.value |
~~result.status~~ |
| Cú pháp CLI | pi-manage config realm create |
~~pi-manage realm~~, ~~pi-manage-admin~~ |
| Backup kèm khoá | pi-manage backup create **-e** |
~~pi-manage backup create~~ |
| Suite APT | noble |
~~jammy~~ |
| Gói rlm_perl | có sẵn trong freeradius |
~~freeradius-perl~~ (không tồn tại) |
| Perl module hay thiếu | liburi-encode-perl |
(tài liệu chính thức quên liệt kê) |
| Đường dẫn module RADIUS | /usr/share/privacyidea/freeradius/ |
~~/etc/freeradius/3.0/mods-config/perl/~~ |
| Đường dẫn ini RADIUS | /etc/privacyidea/rlm_perl.ini |
~~/etc/freeradius/3.0/rlm_perl.ini~~ |
| Bật verify SSL trong ini | SSL_CHECK = true |
~~SSL_CHECK = 1~~ (bị bỏ qua âm thầm) |
| Key module FreeRADIUS 3 | filename = ... |
~~module = ...~~ (deprecated) |
| Registry Windows CP | HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP |
~~...\Netknights\...~~ |
| Bắt OTP chỉ cho RDP | cpus_logon = "1d" (REG_SZ) |
~~cpus_logon = 1 (DWORD)~~ |
| Tắt hoàn toàn CP | cpus_logon = "3d" |
~~cpus_logon = "0"~~ (0 = BẬT cho cả hai!) |
| Tổ hợp cấm | — | 🔴 cpus_* = "3e" → không còn CP nào |
| RDP bắt buộc có | enable_filter = "1" |
(thiếu → Windows dùng System CP) |
| Thứ tự PAM | @include common-auth trước |
~~pam_radius trước~~ → không ai login được |
| Option PAM bắt buộc | force_prompt |
(thiếu → pam_unix dùng lại chuỗi OTP) |
| OpenSSH ≥ 8.7 | KbdInteractiveAuthentication |
~~ChallengeResponseAuthentication~~ |
| UID cho AD | UIDTYPE = objectGUID |
~~để trống~~ (dùng DN → mất token khi chuyển OU) |
| Thuật toán TOTP | sha1 / 6 số / 30 giây |
~~sha256~~ (nhiều app bỏ qua) |
| Nhãn token | tokenlabel={user}@{realm} |
~~{user}~~ (QR không hợp lệ nếu không gắn user) |
| Phiên bản tối thiểu | privacyIDEA ≥ 3.13.1 | < 3.13.1 → backup hỏng trên Python 3.12 |
| Cài Python trên 24.04 | venv | ~~pip install vào system~~ (PEP 668) |
| RADIUS client Linux | require_message_authenticator = **auto** |
~~yes~~ → chặn đứng SSH (pam_radius 2.0.1 không gửi) |
$INCLUDE dictionary |
đường dẫn tuyệt đối | ~~tương đối~~ → FreeRADIUS không khởi động |
| Tạo file ini cho RADIUS | tee /etc/privacyidea/rlm_perl.ini |
~~install -d ... /etc/privacyidea~~ → giết privacyIDEA |
| Chặn theo IP bằng policy | authorized=**deny_access** + client dải cần chặn |
~~grant_access~~ → không chặn gì cả |
| Tạo API key | pi-manage api createtoken -r validate **-u <ten>** |
~~thiếu -u~~ → click báo lỗi |
| passwd resolver | {"**fileName**": "..."} |
~~"Filename"~~ → âm thầm dùng /etc/passwd |
QS_ClientEventLimitCount |
server context (global) | ~~trong <VirtualHost>~~ → Apache fail configtest |
| PAM arg có khoảng trắng | prompt=**[Ma OTP]** |
~~prompt="Ma OTP"~~ → prompt hỏng |
| Cột IP trong audit | client |
~~client_ip~~ → luôn trả null |
timeWindow=180 cho ra |
12 mã hợp lệ (±6 bước) | ~~7 mã~~ — công thức: 2 × timeWindow/timeStep |
Tài liệu này biên soạn cho môi trường lab dựa trên privacyIDEA 3.13.x, FreeRADIUS 3.2.5 và Ubuntu 24.04 LTS. Trước khi áp dụng vào production, hãy chạy đủ chu trình test và diễn tập DR đã mô tả. Cú pháp lệnh có thể thay đổi giữa các phiên bản — luôn đối chiếu với pi-manage --help và tài liệu của đúng bản bạn đang chạy.