Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 18: Cookie, Session, CSRF và băm mật khẩu trong PHP

PHP EP 18: Cookie, Session, CSRF và băm mật khẩu trong PHP

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.

Đăng nhập an toàn cần quản lý mật khẩu và phiên

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):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) trên Alpine Linux 3.21 (aarch64), Zend Engine v4.4.27.
  • Giới hạn kiểm chứng: Kiểm chứng 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).

Băm mật khẩu và CSRF token bảo vệ điều gì

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:

  1. $2y$: Định danh thuật toán bcrypt chuẩn.
  2. 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).
  3. 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']).

Chu trình xác thực người dùng, tái tạo định danh phiên và kiểm chứng mã chống CSRF.
Hình 1. Chu trình xác thực người dùng, tái tạo định danh phiên và kiểm chứng mã chống CSRF. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Kiểm tra mật khẩu và so sánh 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:

  • Đầu ra hiển thị: PassOK=true | WrongPass=false | CsrfOK=true | Tampered=false.
  • Theo PHP Manual: hash_equals, phép so sánh hạn chế rò rỉ nội dung qua thời gian khi hai chuỗi cùng độ dài. Nếu khác độ dài, hàm trả 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().

Cấp lại session ID và nâng cấp mã băm

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:

  • Màn hình in ra: 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).

Kiểm tra TypeError của hash_equals và dọn dẹp

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:

  • Tiến trình kết thúc với 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).
Ranh giới lưu trữ giữa Cookie phía trình duyệt và dữ liệu Session phía máy chủ.
Hình 2. Ranh giới lưu trữ giữa Cookie phía trình duyệt và dữ liệu Session phía máy chủ. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

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"

Đăng xuất và lưu trữ Session trên production

Khi triển khai xác thực trên Production, hãy đảm bảo hai quy tắc:

  1. Quy trình đăng xuất 3 bước chuẩn: Gán $_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ủ.
  2. Lưu trữ Session tập trung trên Redis: Khi chạy nhiều container phía sau Load Balancer, cấu hình session.save_handler = redis để người dùng không bị mất phiên khi request chuyển giữa các container.

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:

  • Câu hỏi 1: Vì sao 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ó.
  • Câu hỏi 2: Tại sao bắt buộc phải gọi 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.
  • Câu hỏi 3: 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ạ.
  • Câu hỏi 4: Vì sao không được dùng 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.

Bài liên quan trong bộ PHP

Tìm hiểu thêm