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 Directorykhông có AD (user nội bộ)

Phiên bản tài liệu: 1.0 — 08/2026

Mục lục
  1. PHẦN 0. SO SÁNH PHƯƠNG ÁN VÀ CHỐT LỰA CHỌN
  2. PHẦN 1. KIẾN TRÚC LAB, CHUẨN BỊ, ÔN LẠI TOTP
  3. PHẦN 2. DỰNG PRIVACYIDEA SERVER TRÊN UBUNTU 24.04
  4. PHẦN 3. USER: RESOLVER + REALM
  5. PHẦN 4. POLICY VÀ ENROLL TOKEN TOTP
  6. PHẦN 5. FREERADIUS — CẦU NỐI TỚI MỌI CLIENT
  7. PHẦN 6. CLIENT WINDOWS — RDP VÀ CONSOLE LOGON
  8. PHẦN 7. CLIENT LINUX — SSH
  9. PHẦN 8. TÍCH HỢP VÀO ỨNG DỤNG WEB
  10. PHẦN 9. BACKUP, DR VÀ HA
  11. PHẦN 10. TROUBLESHOOTING VÀ PHỤ LỤC
  12. PHẦN 11. PROXMOX VE, VMWARE VÀ ỨNG DỤNG WEB BÊN THỨ BA
  13. PHỤ LỤC A. LỆNH THAM CHIẾU NHANH
  14. PHỤ LỤC B. REST API THAM CHIẾU
  15. PHỤ LỤC C. FILE VÀ ĐƯỜNG DẪN QUAN TRỌNG
  16. 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_KEYPI_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, pi thành raspberry
  • Bật PermitRootLogin yes
  • Sửa GRUB thêm net.ifnames=0 → interface đổi tên sau reboot
  • iptables -P INPUT DROP và ghi vào rules.v4
  • chmod 777 -R /etc/multiotpthư mục chứa seed OTP thành world-writable
  • Tạo RADIUS client 0.0.0.0/0 với secret myfirstpass
  • 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:

483 21930sNgười dùng1 token TOTPOTP01 — Ubuntu 24.04 LTS192.168.10.10 · otp01.lab.localprivacyIDEA 3.13WebUI quản trị (HTTPS 443)REST API /validate/checkMariaDB — token + audit log ký RSAFreeRADIUS 3.2 + rlm_perlUDP 1812 auth · 1813 accountinggọi privacyIDEA qua localhostWindowsRDP + console logonCredential Provider 3.8offline OTP khi mất mạngLinuxSSHpam_radius (RADIUS 1812)hoặc PAM native offlineWeb appREST hoặc RADIUS/validate/checkthư viện client riêngHTTPS 443RADIUS 1812HTTPS / RADIUS
Kiến trúc sau khi hoàn thành — một token TOTP dùng cho cả ba môi trường

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í đó, mask 0x7FFFFFFF, rồi mod 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

LAB · lab.local · 192.168.10.0/24DC01 (tuỳ chọn)Windows Server 2022AD DS · DNS · NTP master192.168.10.5OTP01 — máy chínhUbuntu 24.04 LTSprivacyIDEA 3.13 · Apache2+WSGI :443MariaDB (db "pi")FreeRADIUS 3.2 :1812/udp192.168.10.10 / otp01.lab.localLDAPS 636OTP02Ubuntu 24.04standby / HA.10.11WINSRV02Win Server 2022test RDP.10.22LNX01Ubuntu 24.04test SSH.10.31WEB01Ubuntu 24.04Flask + pyotp.10.41WINCLI01Windows 11 Proconsole logon.10.21RADIUS 1812 · HTTPS 443
Sơ đồ lab — OTP01 là máy chính, DC01 chỉ cần khi dùng Active Directory

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/hosts có ánh xạ đúng
  • IP tĩnh, DNS trỏ đúng
  • chrony chạ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_PEPPERSECRET_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_keysprivate.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.cfgprivacyidea: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 rotatebackup 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:

  1. Thêm repo NetKnights suite noble, kiểm tra GPG fingerprint 0940 4ABB EDB3 586D EDE4 AD22 00F7 0D62 AE25 0082.
  2. Xác nhận phiên bản ≥ 3.13.1 (bug backup trên Python 3.12).
  3. apt install privacyidea-apache2 trên máy chuyên dụng (nó xoá symlink của mọi site Apache khác).
  4. pi-manage admin add admin -e ... — việc duy nhất cần làm thủ công.
  5. Thêm PI_BASE_URL, PI_FORCE_HTTPS, PI_ENABLE_CSP vào pi.cfg.
  6. Thay chứng chỉ self-signed bằng cert từ CA nội bộ, SAN có cả DNS lẫn IP.
  7. Bỏ comment cron challenge cleanup.
  8. 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:

REALM "lab"← tên miền logic người dùng gõ khi đăng nhậpRESOLVER "ad_lab"LDAP → Active Directory (DC01)nested group · objectGUIDRESOLVER "local"SQL → bảng trong DB privacyIDEAdùng khi không có ADhoangnvtừ ADTOTP0001gán tokensvc-backupuser nội bộTOTP0002gán token
Realm là tên miền logic; resolver là nguồn user thật. Token gắn vào user trong realm
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à objectGUIDphâ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:

  1. AD: tạo svc-privacyidea (chỉ quyền đọc), tạo group OTP-Users, export CA cert cho LDAPS.
  2. Test bằng ldapsearch trước khi động vào privacyIDEA.
  3. Tạo LDAP resolver với UIDTYPE=objectGUID, NOREFERRALS=1, NOSCHEMAS=1, LDAPS + TLS verify.
  4. Không AD: pi-manage config resolver create_internal local_users — một lệnh là xong.
  5. Gộp cả hai vào một realm: pi-manage config realm create lab ad_lab local_users.
  6. set_default để user không phải gõ @realm.
  7. 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 1739404829173940 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)

  1. Tokens → Enroll Token
  2. Token type: TOTP
  3. Username: gõ tên user (có autocomplete từ resolver), chọn Realm
  4. PIN: đặt nếu dùng otppin=tokenpin; để trống nếu dùng otppin=userstore
  5. Tham số TOTP: Length = 6, Timestep = 30, Hash = sha1giữ mặc định
  6. Bấm Enroll Token → hiện QR code
  7. Người dùng quét bằng app
  8. 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
  9. 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 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).

  1. Người dùng vào https://otp01.lab.local/
  2. Đăng nhập bằng mật khẩu AD (chọn đúng realm)
  3. Tokens → Enroll Token → TOTP
  4. Quét QR, nhập mã xác nhận
  5. 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:

  1. Hiểu policy engine: 11 scope, cộng dồn, priority số nhỏ thắng, scope trống = cho phép tất cả.
  2. Đặt otppin — quyết định lớn nhất: userstore (dùng mật khẩu AD) hoặc tokenpin (PIN riêng).
  3. Tạo bộ policy nền: enroll_base (có verify_enrollmentmax_active_token_per_user=2), selfservice, webui_base.
  4. Siết Max failcount = 6timeWindow = 60 giây; thêm fail2ban.
  5. Enroll token: ưu tiên self-service, seed không qua tay admin.
  6. Bàn giao token đúng quy tắc: không bao giờ gửi QR qua email/chat.
  7. Test bằng /validate/check và nhớ kiểm result.value, không phải result.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.

Linux SSHVPN / RRASSwitch · APFirewallRADIUS 1812/udpFreeRADIUS 3.2+ rlm_perlkho ngôn ngữ chung củamọi thiết bị mạngAccess-RequestAccess-ChallengeAccess-Accept / RejectprivacyIDEA/validate/checknơi duy nhất giữ seedvà ra quyết địnhHTTPS localhostWindows Credential ProviderWeb app / scriptHTTPS 443 — gọi thẳng, không qua RADIUS
RADIUS là ngôn ngữ chung của thiết bị mạng; client hiện đại gọi thẳng REST API

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-privacyideainstance đã 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.inikhuyế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.cfgenckeytoà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-AddressPacket-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:

  1. Không khai perl_flags. Mẫu chính thức của privacyIDEA cố ý bỏ nó. File perl mặc định của FreeRADIUS có perl_flags = "-T" (taint mode) — copy sang đây sẽ gây lỗi với LWP.
  2. Dùng filename, không dùng module. Key module đã deprecated. Tài liệu cũ viết cho FreeRADIUS 2 dùng module = ... — 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: radtestradclient 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:

  1. apt install freeradius freeradius-utilskhông có gói freeradius-perl, rlm_perl.so nằm trong gói chính.
  2. Cài 7 Perl module, nhớ liburi-encode-perl (tài liệu chính thức quên liệt kê).
  3. Đặt module tại /usr/share/privacyidea/freeradius/privacyidea_radius.pm, phiên bản ≥ 3.4.3.
  4. Đặt ini tại /etc/privacyidea/rlm_perl.ini, SSL_CHECK = true (chữ thường, đúng chữ "true").
  5. Tạo instance perl perl-privacyidea riêng, không sửa file perl mặc định, không thêm perl_flags.
  6. Sửa authorize{}authenticate{} của site default; giữ nguyên Post-Auth-Type Challenge.
  7. Khai client với secret riêng ≥ 24 ký tự và require_message_authenticator = auto (đặt yes sẽ chặn đứng SSH vì libpam-radius-auth 2.0.1 của noble không gửi attribute đó).
  8. Test bằng freeradius -X, tìm dòng Config 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.

1Người dùngusername + password+ mã OTP 6 số2WINSRV02privacyIDEACredential Provider 3.83OTP01kiểm tra OTPtrong time window4LSASS → ADxác thực mật khẩunhư bình thườngHTTPS 443 → /validate/check{"result": {"value": true}}Mật khẩu vẫn do AD kiểm tra — privacyIDEA chỉ trả lời đúng một câu hỏi: mã OTP này có hợp lệ không?
Luồng đăng nhập Windows — privacyIDEA chỉ xác nhận OTP, mật khẩu vẫn do AD kiểm tra

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}
  1. Copy PrivacyIDEACredentialProvider.dll (và PrivacyIDEACredentialProviderFilter.dll) vào %windir%\System32
  2. Tạo key HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{7BAF541E-...}, giá trị mặc định = PrivacyIDEACredentialProvider
  3. Tại HKEY_CLASSES_ROOT\CLSID\{7BAF541E-...}\InprocServer32: giá trị mặc định = PrivacyIDEACredentialProvider.dll, thêm REG_SZ ThreadingModel = Apartment
  4. 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:

  1. Các setting cpus_* chỉ có hiệu lực khi enable_filter = 1 (từ v3.2).
  2. enable_filter là 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_passwordTWO_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_sensitive
  • user_agent_hide_computer_name
  • realm-mapping (là sub-key, không phải value)

Cách 2 — MSI Transform bằng Orca:

  1. Mở MSI trong Orca → Transform → New Transform
  2. Sửa bảng Property, thêm các property ở trên
  3. Transform → Generate Transform → lưu privacyidea-cp.mst
  4. 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

  1. Từ WINCLI01: mstsc /v:192.168.10.22
  2. Màn hình đăng nhập phải hiện tile của privacyIDEA
  3. Nhập LAB\hoangnv + mật khẩu AD
  4. Bước 2: nhập mã OTP 6 số
  5. Đă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-glassphả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:

  1. 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.
  2. Cài MSI 3.8.0 trên máy đích RDP.
  3. Registry tại HKLM\SOFTWARE\Netknights GmbH\PrivacyIDEA-CP (có dấu cách và chữ GmbH).
  4. enable_filter = 1bắt buộc cho RDP.
  5. Bắt đầu bằng cpus_logon = "1d" (chỉ RDP, giữ đường lui). Nhớ: 0 = cả hai, 3 = tắt, 3e là tổ hợp chết.
  6. excluded_account đúng định dạng DOMAIN\username, và test nó.
  7. two_step_send_password = 1 nếu dùng otppin=userstore.
  8. 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 — 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:

  1. 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.
  2. Mọi thao tác sửa file thực hiện ở Terminal A.
  3. Mở Terminal B để test đăng nhập mới.
  4. 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 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=1nhả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

  1. WebUI → Tokens → chọn token của user
  2. Xuống cuối trang → Application ⇒ Offline
  3. Đặt số lượng mã cần cache (VD: 100)
  4. Người dùng đăng nhập online một lần → mã được tải xuống pam.sqlite
  5. 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:

  1. Giữ Terminal A mở, backup /etc/pam.d/sshd + sshd_config + common-auth.
  2. chrony chạy, radtest ra Access-Accept trước khi động vào PAM.
  3. Cài libpam-radius-auth, khai server chính + backup trong /etc/pam_radius_auth.conf, chmod 600.
  4. sshd_config: UsePAM yes, KbdInteractiveAuthentication yes, AuthenticationMethods publickey,keyboard-interactive. Kiểm tra /etc/ssh/sshd_config.d/.
  5. 🔴 /etc/pam.d/sshd: @include common-auth TRƯỚC, rồi auth required pam_radius_auth.so force_prompt retry=1 prompt=OTP.
  6. sshd -tsystemctl restart ssh → test ở Terminal B.
  7. Máy cần offline: dùng Phương án B với sqlfile=.
  8. Automation: Match User + AuthenticationMethods publickey + siết authorized_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 = {...}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)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:

  1. 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ả.
  2. 🔴 Đọc result.value, không đọc result.status. Nhầm là lỗ hổng xác thực.
  3. Bảo vệ /validate/check: giới hạn IP bằng policy + API key + rate limit ở Apache.
  4. Client: không retry POST xác thực (gây replay), verify=True với CA nội bộ, fail closed khi server chết.
  5. Không cấp session trước khi qua yếu tố 2; session.clear() sau khi thành công.
  6. Deploy: venv (PEP 668), gunicorn + systemd hardening, nginx TLS + limit_req.
  7. 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 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:

  1. File backup được chmod 0600 tự động.
  2. Script tạo tạm /etc/privacyidea/mysql.cnf chứa credential DB (để không lộ mật khẩu trên command line), rồi xoá.
  3. Nếu mysqldump fail, 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

Client — Windows CP · pam_radius · web appmỗi loại có cơ chế failover riêng (xem bảng 9.6.4)OTP01Apache2 + mod_wsgiFreeRADIUS 3.2cùng enckey, cùng pi.cfgOTP02Apache2 + mod_wsgiFreeRADIUS 3.2cùng enckey, cùng pi.cfgMariaDB Galera Clusterhoặc master–replica · PI_DB_SAFE_STORE = Truecùng một database
HA hai node — chung database, chung enckey; failover do phía client quyết định

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:

  1. 🔴 pi-manage backup create PHẢI có cờ -e — mặc định không kèm enckey.
  2. Script backup tự kiểm tra file backup có chứa enckey không, và fail nếu không có.
  3. 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ũ.
  4. Nhân bản ra ≥ 2 nơi (quy tắc 3-2-1), có checksum, có giám sát tuổi backup.
  5. Restore giải nén thẳng ra / — chỉ chạy trên máy sạch.
  6. Verify restore bằng một token thật, không chỉ đếm số dòng trong DB.
  7. Dựng OTP02 dùng chung DB (HA thật) hoặc warm standby (đơn giản hơn). Copy enckey giống hệt.
  8. Cấu hình failover ở từng loại client, và đặt DNS TTL = 60 giây.
  9. Quy trình reset token bắt buộc có bước xác minh danh tính.
  10. 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-perltà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 60gó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 — %_ 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 customuserattributeinternaluserattribute 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ố.

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/sshdsshd_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.

Ứng dụng hỗ trợ gì?RADIUSkhả năng của appFreeRADIUS 3.2 trên OTP01Fortigate · Cisco · pfSense · Horizon · Proxmox (qua PAM)LDAPkhả năng của appprivacyIDEA LDAP-Proxyapp chỉ biết bind LDAP: GitLab cũ, Jira, NASSAML / OIDCkhả năng của appKeycloak + keycloak-providerProxmox VE (realm OIDC) · Grafana · GitLab · NextcloudAD FSkhả năng của appAD FS + plugin privacyIDEAVMware vCenter 7/8 (Identity Federation)Plugin sẵn cókhả năng của appplugin của privacyIDEANextcloud/ownCloud · WordPress · Dokuwiki · TYPO3 · OTRSKhông hỗ trợ gìkhả năng của appReverse proxy chặn trướcnginx auth_request · Apache mod_authn_radius · ForwardAuthprivacyIDEAOTP01 · nơi duy nhấtgiữ seed và ra quyết định/validate/checkaudit log ký số RSANguyên tắc: không app nào giữ seed. Mọi đường đều quy về một điểm quyết định duy nhất.
Chọn đường tích hợp theo giao thức mà ứng dụng đã biết nói

🔴 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 + 123456MatKhauAD123456
tokenpin PIN + 123456PIN123456
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:

  1. /etc/pam.d/common-auth dùng chung cho cả SSH của node Proxmox. Sửa sai là mất luôn cả web UI lẫn SSH.
  2. 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.
  3. 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.
  4. 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:

  1. Dựng Keycloak (container hoặc VM riêng), tạo realm và client cho Proxmox.
  2. Cài keycloak-provider của privacyIDEA vào Keycloak, trỏ về https://otp01.lab.local.
  3. Sửa authentication flow của Keycloak: sau bước password, thêm bước privacyIDEA OTP.
  4. 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:ô 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:

  1. 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).
  2. Cài plugin xác thực privacyIDEA cho AD FS, trỏ về https://otp01.lab.local.
  3. Trong AD FS, bật plugin làm Additional Authentication Method cho relying party của vCenter.
  4. 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 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 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 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, GluuAD 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:

  1. Đăng nhập thành công bằng mật khẩu + OTP đúng.
  2. Đăng nhập thất bại khi OTP sai — và log ghi lại được ở audit của privacyIDEA.
  3. Dùng lại mã vừa dùng (replay) → phải bị từ chối.
  4. 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.

Tài liệu triển khai thực tế privacyIDEA 3.13.x trên Ubuntu 24.04 LTS + FreeRADIUS 3.2.5 — bao gồm cả kịch bản có Active Directory và không có AD. Phiên bản 1.0, 08/2026.
Zalo