
PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP
Quản lý giao dịch cơ sở dữ liệu bằng beginTransaction, commit và rollBack trong PHP PDO, thực hành bật ràng buộc khóa ngoại và đóng gói truy vấn vào Repository.

Thiết lập Cookie và Session an toàn trong PHP với HttpOnly, SameSite, thực hành cấp mới session ID, kiểm tra CSRF token và băm mật khẩu bằng password_hash.
Giao thức HTTP về bản chất là phi trạng thái (stateless): mỗi request gửi lên máy chủ hoàn toàn độc lập với request trước đó. Để duy trì trạng thái đăng nhập, ứng dụng PHP kết hợp Cookie phía trình duyệt với Session phía máy chủ. Tuy nhiên, trong quá trình xây dựng phân hệ xác thực, có ba lỗ hổng nghiêm trọng rất hay mắc phải: dùng hàm băm nhanh như md5() hoặc sha1() để lưu mật khẩu; không gọi session_regenerate_id(true) ngay sau khi đăng nhập thành công (tạo cơ hội cho tấn công cố định phiên - Session Fixation); và xử lý biểu mẫu thay đổi dữ liệu mà không kiểm tra CSRF Token bằng hàm so sánh thời gian hằng số hash_equals().
Bài học khép lại Chặng 4 này hướng dẫn bạn thiết lập cờ bảo mật Cookie (HttpOnly, Secure, SameSite), quản lý vòng đời Session an toàn, tạo và kiểm chứng CSRF Token bằng random_bytes() + hash_equals(), và băm mật khẩu chuẩn công nghiệp với password_hash() / password_verify() trong PHP 8.4. Bài viết nối tiếp PHP EP 17: Xử lý form HTTP và mã hóa đầu ra trong PHP và mở đường cho Chặng 5 tại PHP EP 19: PDO và truy vấn tham số hóa trong PHP. Tài liệu tham chiếu gồm PHP Manual: Sessions and Security, Password Hashing và CSPRNG Functions.
Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):
php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).PHP 8.4.27 (cli) trên Alpine Linux 3.21 (aarch64), Zend Engine v4.4.27.password_hash/password_verify, random_bytes, hash_equals và vòng đời Session cục bộ; không tuyên bố kiểm chứng lưu trữ Session phân tán trên cụm Redis.Bảng dưới đây tổng hợp hợp đồng kỹ thuật của 4 trụ cột bảo mật phiên và mật khẩu trong PHP 8.4:
| Hạng mục bảo mật | Hàm / Cấu hình chuẩn | Hành vi thực tế trong PHP 8.4 | Điều kiện / Không nên giả định |
|---|---|---|---|
| Cờ bảo vệ Cookie phiên | httponly: true, secure: true, samesite: 'Lax' |
HttpOnly chặn JS đọc `document.cookie`; Secure chỉ gửi qua HTTPS; SameSite=Lax/Strict hạn chế gửi cookie tự động từ trang lạ. |
Phải gọi session_set_cookie_params([...]) trước khi gọi session_start(). |
| Chống Session Fixation | session_regenerate_id(true) |
Cấp mã định danh Session ID hoàn toàn mới và xóa tệp phiên cũ ngay sau khi người dùng đăng nhập hoặc nâng quyền. | Bật thêm ini_set('session.use_strict_mode', '1') để từ chối Session ID chưa từng được máy chủ khởi tạo. |
| Sinh & Kiểm tra CSRF Token | bin2hex(random_bytes(32)) + hash_equals($expected, $user) |
random_bytes() lấy entropy mật mã học từ nhân HĐH; hash_equals() so sánh hai chuỗi với thời gian thực thi không đổi. |
Tuyệt đối không dùng rand(), mt_rand() hay uniqid() để tạo token bảo mật vì có thể dự đoán được! |
| Băm & Kiểm tra mật khẩu | password_hash($p, PASSWORD_DEFAULT) & password_verify($p, $h) |
Tự động sinh muối ngẫu nhiên (`salt`) riêng cho từng mật khẩu và nhúng cả thuật toán, chi phí (`cost = 12` trên PHP 8.4) cùng salt vào chuỗi kết quả 60+ ký tự. | Cột lưu mã băm trong DB phải có độ dài tối thiểu VARCHAR(255). |
Làm sao hàm password_verify() biết được mật khẩu người dùng nhập vào là đúng mà không cần lưu cột salt riêng trong cơ sở dữ liệu? Theo PHP Manual: password_hash, chuỗi kết quả 60 ký tự như $2y$12$R9h/cIPz0gi... đã tự gói gọn đầy đủ 3 thông tin:
$2y$: Định danh thuật toán bcrypt chuẩn.12$: Hệ số chi phí tính toán (cost = 12, tức $2^{12} = 4.096$ vòng lặp làm chậm có chủ đích; trên PHP 8.4 chi phí mặc định đã nâng từ 10 lên 12).22 ký tự tiếp theo: Chuỗi muối ngẫu nhiên (salt) duy nhất của bản ghi đó, nối liền ngay sau là 31 ký tự kết quả băm.Khi gọi password_verify($input, $storedHash), PHP đọc phần đầu của $storedHash để lấy thuật toán, chi phí và đúng chuỗi salt đó, đem băm thử $input rồi so sánh bằng thuật toán thời gian hằng số. Nhờ vậy, hai người dùng đặt trùng mật khẩu vẫn có hai chuỗi hash khác biệt hoàn toàn, vô hiệu hóa bảng tra cứu Rainbow Tables.
Để ngăn một trang web độc hại bên ngoài lừa trình duyệt của người dùng đã đăng nhập gửi biểu mẫu POST ngầm (CSRF), máy chủ sinh một khóa ngẫu nhiên bằng bin2hex(random_bytes(32)) lưu vào $_SESSION['csrf_token'], nhúng vào trường ẩn của biểu mẫu và kiểm chứng bằng hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']).

Chúng ta sẽ kiểm chứng chu trình băm mật khẩu bằng password_hash(), đối chiếu mật khẩu bằng password_verify(), sinh mã CSRF bằng random_bytes() và kiểm tra bằng hash_equals().
Bước 1: Tạo vùng làm việc tạm an toàn
Mục đích: Khởi tạo thư mục thực hành riêng trong /tmp.
WORKDIR="$(mktemp -d /tmp/php-ep18-XXXXXX)"
cd "$WORKDIR"
pwd
Bước 2: Tạo kịch bản auth_csrf.php kiểm tra mật khẩu và mã chống CSRF
Mục đích: Xác minh password_verify() nhận diện đúng mật khẩu gốc, từ chối mật khẩu sai và hash_equals() đối chiếu chính xác token CSRF.
cat <<'EOF' > "$WORKDIR/auth_csrf.php"
<?php
declare(strict_types=1);
$hash = password_hash('S3cret!Pass#2026', PASSWORD_DEFAULT);
$verified = password_verify('S3cret!Pass#2026', $hash);
$wrongPass = password_verify('wrong', $hash);
$sessionToken = bin2hex(random_bytes(16));
$validCsrf = hash_equals($sessionToken, $sessionToken);
$tamperedCsrf = hash_equals($sessionToken, '00000000000000000000000000000000');
echo "PassOK=" . ($verified ? 'true' : 'false')
. " | WrongPass=" . ($wrongPass ? 'true' : 'false')
. " | CsrfOK=" . ($validCsrf ? 'true' : 'false')
. " | Tampered=" . ($tamperedCsrf ? 'true' : 'false') . "\n";
EOF
php "$WORKDIR/auth_csrf.php"
Kết quả quan sát được:
PassOK=true | WrongPass=false | CsrfOK=true | Tampered=false.false ngay; truyền token phía máy chủ ở tham số thứ nhất, dữ liệu người dùng ở tham số thứ hai. Lab chỉ kiểm tra kết quả so sánh, không đo khả năng chống tấn công thời gian.Cảnh báo an toàn: Không bao giờ tự cắt ngắn mật khẩu trước khi truyền vào password_hash().
Khi nâng hệ số cost của bcrypt (ví dụ từ 10 lên 12 trên PHP 8.4), ngay lúc người dùng đăng nhập và vượt qua password_verify(), bạn gọi password_needs_rehash($storedHash, PASSWORD_DEFAULT, ['cost' => 12]) để tự động băm lại và cập nhật xuống DB.
Bước 3: Tạo kịch bản session_rehash.php kiểm chứng tái tạo Session ID và nâng cấp mã băm
Mục đích: Khởi tạo phiên làm việc, đổi mã phiên bằng session_regenerate_id(true) và kiểm tra password_needs_rehash().
cat <<'EOF' > "$WORKDIR/session_rehash.php"
<?php
declare(strict_types=1);
session_save_path(__DIR__);
ini_set('session.use_strict_mode', '1');
ini_set('session.use_cookies', '0');
session_start();
$oldId = session_id();
$_SESSION['user_id'] = 42;
session_regenerate_id(true);
$newId = session_id();
session_write_close();
$oldCostHash = password_hash('MyPass#123', PASSWORD_BCRYPT, ['cost' => 10]);
$needsUpgrade = password_needs_rehash($oldCostHash, PASSWORD_BCRYPT, ['cost' => 12]);
echo "IdChanged=" . ($oldId !== $newId ? 'true' : 'false')
. " | NeedsRehash=" . ($needsUpgrade ? 'true' : 'false') . "\n";
EOF
php "$WORKDIR/session_rehash.php"
Kết quả quan sát được:
IdChanged=true | NeedsRehash=true. session_regenerate_id(true) cấp mới $newId khác hoàn toàn $oldId nhưng vẫn giữ nguyên $_SESSION['user_id'] = 42 và xóa tệp phiên cũ.Cảnh báo an toàn: Sau khi đọc/ghi $_SESSION xong, nếu request còn phải chạy tác vụ nặng, hãy gọi session_write_close() sớm để nhả khóa tệp Session (tránh Session Locking).
Nếu người dùng không gửi trường csrf_token (giá trị là null) mà bạn truyền thẳng vào hash_equals(), PHP sẽ ném lỗi TypeError.
Bước 4: Kiểm tra hành vi của hash_equals() khi thiếu tham số chuỗi
Mục đích: Xác nhận hash_equals() yêu cầu nghiêm ngặt cả hai đối số đều phải là string.
set +e
php -r 'hash_equals("secret_token", null);'
RC=$?
set -e
echo "RC=$RC"
Kết quả quan sát được:
RC=255 kèm lỗi: Fatal error: Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given. Hãy luôn kiểm tra is_string($userToken) && hash_equals($sessionToken, $userToken).
Bước 5: Dọn dẹp vùng làm việc tạm
Mục đích: Xóa thư mục thực hành trong /tmp.
rm -rf "$WORKDIR"
Khi triển khai xác thực trên Production, hãy đảm bảo hai quy tắc:
$_SESSION = [];, xóa Cookie trên trình duyệt bằng setcookie(session_name(), '', time() - 3600, ...), và gọi session_destroy() để xóa dữ liệu trên máy chủ.session.save_handler = redis để người dùng không bị mất phiên khi request chuyển giữa các container.Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:
password_verify($password, $hash) kiểm tra được mật khẩu mà không cần truyền thêm biến $salt? -> Vì chuỗi kết quả của password_hash() đã tự chứa sẵn thuật toán, hệ số cost và chính chuỗi salt ở phần đầu của nó.session_regenerate_id(true) ngay khi đăng nhập thành công? -> Để chống tấn công cố định phiên (Session Fixation), làm vô hiệu hóa mọi mã Session ID cũ kẻ tấn công có thể đã biết trước.HttpOnly và SameSite=Lax trên Cookie phiên bảo vệ khỏi nguy cơ nào? -> HttpOnly ngăn JavaScript đọc trộm document.cookie, còn SameSite ngăn trình duyệt tự động gửi Cookie trong các yêu cầu giả mạo từ trang web lạ.rand(), mt_rand() hay uniqid() để sinh mã CSRF Token? -> Vì chúng không an toàn mật mã học (dễ bị suy đoán). Bắt buộc phải dùng random_bytes() hoặc random_int().Khép lại Chặng 4, bạn đã nắm vững bảo mật ứng dụng Web HTTP. Hãy bước sang Chặng 5 tại PHP EP 19: PDO và truy vấn tham số hóa trong PHP để làm việc với cơ sở dữ liệu quan hệ qua PDO.
Ôn bài trước: PHP EP 17: Xử lý form HTTP và mã hóa đầu ra trong PHP. Học tiếp: PHP EP 19: PDO và truy vấn tham số hóa trong PHP.

Quản lý giao dịch cơ sở dữ liệu bằng beginTransaction, commit và rollBack trong PHP PDO, thực hành bật ràng buộc khóa ngoại và đóng gói truy vấn vào Repository.

Thiết kế cấu trúc thư mục dự án PHP tách biệt nghiệp vụ và giao diện, thực hành viết kịch bản kiểm thử tự động xác minh nhánh lỗi và đọc Stack Trace để gỡ lỗi.

Tổng hợp kiến thức toàn khóa qua dự án ứng dụng quản lý Ticket bằng PHP và PDO: kết hợp Property Hooks, Enum, Transaction, bảo mật biểu mẫu HTTP và bộ kiểm thử.

Phân tích vòng đời HTTP Request/Response trong PHP qua $_GET, $_POST, $_SERVER, thực hành kiểm tra dữ liệu bằng filter_var và mã hóa đầu ra chống tấn công XSS.