EzCode # Bài 14 — Đăng ký tài khoản
Học xong bài này bạn sẽ:
- Băm mật khẩu bằng
password_hash()và hiểu vì sao không bao giờ lưu mật khẩu gốc- Viết trọn luồng một form: hiện, kiểm tra, báo lỗi, lưu, chuyển hướng
- Có chức năng đăng ký tài khoản chạy thật trên EzCode
1. Vấn đề đặt ra
Đăng ký tài khoản nghe đơn giản: nhận dữ liệu form, chèn vào bảng users. Xong.
Nhưng có bốn việc phải làm cho đúng, và làm sai bất kỳ việc nào cũng để lại hậu quả:
- Kiểm tra dữ liệu. Người dùng để trống tên, gõ email sai định dạng, gõ hai mật khẩu khác nhau.
- Chặn trùng email. Cột
emailtrongezcode.sqlkhông có ràng buộcUNIQUE, nên database sẽ vui vẻ cho phép hai người cùng email. Sau đó chức năng đăng nhập hỏng vìgetByEmail()không biết trả về ai. - Không bao giờ lưu mật khẩu dạng chữ thường. Đây là việc quan trọng nhất của bài.
- Không cho người dùng tự chọn vai trò. Nếu để họ gửi lên
role, ai cũng tự phong mình làm giảng viên được.
Bài này giải quyết cả bốn.
2. Kiến thức mới
2.1. Vì sao không bao giờ lưu mật khẩu gốc
Giả sử bạn lưu thẳng mật khẩu vào cột password:
| id | password | |
|---|---|---|
| 34 | hoang@gmail.com | 12345abcd |
Ngày database bị lộ — do lỗi bảo mật, do nhân viên cũ, do sao lưu để nhầm chỗ — kẻ tấn công có ngay danh sách email kèm mật khẩu.
Và đây là phần tệ nhất: rất nhiều người dùng chung một mật khẩu cho nhiều website. Lộ mật khẩu EzCode nghĩa là kẻ tấn công thử ngay email và mật khẩu đó trên Gmail, Facebook, ngân hàng.
Một vụ lộ dữ liệu ở website học trực tuyến có thể dẫn tới mất tài khoản ngân hàng của người dùng. Trách nhiệm đó thuộc về bạn.
Giải pháp: không lưu mật khẩu, lưu chuỗi băm của nó.
2.2. Hàm băm là gì
Hàm băm biến một chuỗi bất kỳ thành một chuỗi khác có độ dài cố định. Hai tính chất quan trọng:
- Một chiều. Từ mật khẩu tính ra chuỗi băm thì dễ. Từ chuỗi băm tìm lại mật khẩu thì gần như không thể.
- Cùng đầu vào cho cùng đầu ra — với hàm băm đơn giản.
Nhưng tính chất 2 lại là một vấn đề. Nếu 12345 luôn cho ra cùng một chuỗi băm, kẻ tấn công chỉ cần dựng sẵn một bảng khổng lồ gồm hàng tỷ mật khẩu phổ biến và chuỗi băm tương ứng, rồi tra ngược. Bảng đó gọi là rainbow table.
Giải pháp là thêm muối.
2.3. Muối và password_hash()
Muối (salt) là một chuỗi ngẫu nhiên được trộn vào mật khẩu trước khi băm.
<?php
echo password_hash("12345", PASSWORD_DEFAULT);
// $2y$10$N9qo8uLOickgx2ZMRZoMye1J9Fx3vGXqTFVQ8Kp0kDqCJmDMHZMz2
echo password_hash("12345", PASSWORD_DEFAULT);
// $2y$10$8fXzKfPqL2mNxRtYuIoP3eK5jH7gF9dS1aQ4wE6rT8yU0iO2pA3sC
?>
Cùng một mật khẩu, hai lần chạy cho ra hai chuỗi khác nhau — vì mỗi lần một muối ngẫu nhiên khác.
Nhờ đó rainbow table trở nên vô dụng: kẻ tấn công phải dựng bảng riêng cho từng muối, mà mỗi người dùng một muối khác nhau.
Cấu trúc chuỗi băm dài 60 ký tự:
$2y$10$N9qo8uLOickgx2ZMRZoMye1J9Fx3vGXqTFVQ8Kp0kDqCJmDMHZMz2
└┬─┘└┬┘└──────────┬──────────┘└─────────────┬─────────────┘
│ │ │ │
│ │ muối 22 ký tự chuỗi băm 31 ký tự
│ hệ số công việc
thuật toán bcrypt
Hệ số công việc 10 nghĩa là hàm chạy 2¹⁰ vòng lặp. Con số càng cao càng chậm — và đó là cố ý. Chậm với bạn thì không sao (một lần đăng nhập), nhưng chậm với kẻ tấn công thì rất đáng kể (họ cần thử hàng tỷ lần).
Muốn tăng độ khó:
password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
PASSWORD_DEFAULT là hằng số chỉ thuật toán mặc định của phiên bản PHP hiện tại. Hiện nay là bcrypt. Dùng hằng số này thay vì tên thuật toán cụ thể là để khi PHP nâng cấp lên thuật toán mạnh hơn, code của bạn tự hưởng lợi.
2.4. Vì sao cột password phải dài
Bảng users khai báo password varchar(225).
Chuỗi băm bcrypt dài đúng 60 ký tự, nên 225 là thừa. Nhưng thừa còn hơn thiếu:
- Nếu cột chỉ
varchar(50), MySQL sẽ cắt cụt chuỗi băm mà không báo lỗi rõ ràng. - Chuỗi băm bị cắt thì
password_verify()luôn thất bại. - Bạn sẽ ngồi gỡ lỗi hàng giờ mà không hiểu vì sao mật khẩu đúng vẫn không đăng nhập được.
Khuyến nghị chuẩn là varchar(255) — đủ chỗ cho cả những thuật toán tương lai dài hơn bcrypt.
2.5. Luồng đầy đủ của một trang có form
Đây là khuôn mẫu quan trọng nhất của Phần III. Bạn sẽ gặp lại nó ở Bài 15, 16, 18, 19.
public function register()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 1. Lấy dữ liệu
// 2. Kiểm tra, gom lỗi vào $errors
// 3. Nếu không lỗi: xử lý, rồi chuyển hướng + exit()
// Nếu có lỗi: không làm gì, để chảy xuống dưới
}
// 4. Hiện form (cả khi mở lần đầu lẫn khi có lỗi)
include_once "./Views/user_register.php";
}
Điểm tinh tế: lệnh nạp View nằm ngoài khối if.
Nhờ vậy nó chạy trong hai tình huống:
- Người dùng mở trang lần đầu bằng GET — khối
ifbị bỏ qua, form hiện ra trống. - Người dùng gửi form nhưng có lỗi — khối
ifchạy nhưng không chuyển hướng, luồng chảy tiếp xuống và form hiện lại kèm lỗi.
Còn khi thành công thì header() cộng exit() dừng hẳn, không bao giờ tới dòng nạp View.
2.6. Kiểm tra email trùng
$existingUser = $userModel->getByEmail($email);
if ($existingUser) {
$errors[] = "Email đã được sử dụng";
}
getByEmail() dùng queryOne(), trả về mảng nếu tìm thấy hoặc false nếu không. Nên if ($existingUser) đủ để phân biệt.
Đây là biện pháp phòng thủ duy nhất chống email trùng, vì cột
ezcode.sqlkhông có ràng buộcUNIQUE.Kiểm tra ở tầng PHP có một điểm yếu gọi là tranh chấp thời điểm: nếu hai người cùng đăng ký một email trong cùng một phần nghìn giây, cả hai đều thấy "chưa tồn tại" và cả hai đều chèn thành công.
Xác suất rất thấp với một website học tập, nhưng cách đúng là thêm ràng buộc ở database:
ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`);Khi đó MySQL bảo đảm tuyệt đối, và PHP chỉ cần bắt lỗi trùng khóa.
2.7. Gán cứng vai trò
$role = 'student'; // Mặc định role student
Dòng này ngắn nhưng là một quyết định bảo mật quan trọng.
Nếu tác giả viết $role = $_POST['role'] ?? 'student';, kẻ xấu chỉ cần mở Developer Tools thêm một ô ẩn vào form:
<input type="hidden" name="role" value="teacher">
Và họ trở thành giảng viên, vào được dashboard, tạo được khóa học, xem được danh sách học viên.
Nguyên tắc chung: mọi trường quyết định quyền hạn phải do máy chủ gán, không bao giờ nhận từ form.
Hệ quả với EzCode: không có cách nào tự đăng ký tài khoản giảng viên. Muốn có, quản trị viên phải vào phpMyAdmin đổi cột role bằng tay. Đây là hạn chế chức năng, nhưng là lựa chọn an toàn.
2.8. Giữ lại dữ liệu khi form báo lỗi
<input type="text" name="name" value="<?= isset($_POST['name']) ? htmlspecialchars($_POST['name']) : '' ?>" required>
Khi form báo lỗi, người dùng không phải gõ lại từ đầu.
Vì sao ở đây có htmlspecialchars()?
Vì $_POST['name'] là dữ liệu người dùng gõ vào, và nó sắp được in vào giữa một thuộc tính HTML.
Nếu người dùng gõ tên là:
" onmouseover="alert('hack')
thì không có htmlspecialchars(), HTML sinh ra sẽ là:
<input type="text" name="name" value="" onmouseover="alert('hack')" required>
Dấu nháy kép trong dữ liệu đã đóng thuộc tính value sớm, và phần còn lại trở thành một thuộc tính JavaScript mới. Rê chuột qua ô là mã chạy.
htmlspecialchars() đổi " thành ", < thành <, > thành >, & thành &. Trình duyệt hiển thị đúng ký tự gốc nhưng không coi chúng là cú pháp HTML.
Ghi nhớ nguyên tắc: lọc dữ liệu khi in ra, không phải khi lưu vào. Lưu nguyên bản để dữ liệu không bị hỏng; lọc lúc hiển thị để trình duyệt không hiểu nhầm.
3. Áp dụng vào EzCode
3.1. Thêm ba phương thức vào Model User
Chèn vào Models/User.php sau getAllTeacher().
📁
Models/User.php— sửa
function getById($id)
{
$sql = "SELECT * FROM users WHERE id=?";
return $this->db->queryOne($sql, $id);
}
function getByEmail($email)
{
$sql = "SELECT * FROM users WHERE email=?";
return $this->db->queryOne($sql, $email);
}
function create($name, $email, $password, $phone, $role)
{
$sql = "INSERT INTO users (name, email, password, phone, role) VALUES (?, ?, ?, ?, ?)";
return $this->db->insert($sql, $name, $email, $password, $phone, $role);
}
getById() lấy người dùng theo id. Bài này chưa dùng, nhưng Bài 16 cần nó cho trang hồ sơ.
getByEmail() lấy người dùng theo email. Dùng cho hai việc: kiểm tra trùng khi đăng ký, và tìm tài khoản khi đăng nhập ở Bài 15.
create() chèn người dùng mới. Năm dấu ? ứng với năm tham số, đúng thứ tự. Đảo thứ tự thì tên vào cột email, không có lỗi nào báo cả — chỉ dữ liệu sai.
Chú ý nó gọi $this->db->insert() chứ không phải query(). Nhớ lại Bài 8: insert() trả về lastInsertId(), tức id vừa tạo. Số đó khác 0 nên if ($result) coi là thành công.
Tham số $password ở đây là chuỗi băm, không phải mật khẩu gốc. Việc băm do Controller làm trước khi gọi. Model không biết và không cần biết.
Đây là một quyết định thiết kế đáng bàn: có người cho rằng Model nên tự băm để không ai quên. Cách của EzCode giữ Model thuần túy về dữ liệu, nhưng đòi hỏi mọi nơi gọi create() đều phải nhớ băm trước.
3.2. Tạo Controller
Tạo file mới ở thư mục Controllers.
📁
Controllers/UserController.php— tạo mới
public function register()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = $_POST['name'] ?? '';
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$confirm_password = $_POST['confirm_password'] ?? '';
$phone = $_POST['phone'] ?? '';
$role = 'student'; // Mặc định role student
// Validation
$errors = [];
if (empty($name)) $errors[] = "Tên không được để trống";
if (empty($email)) $errors[] = "Email không được để trống";
if (empty($password)) $errors[] = "Mật khẩu không được để trống";
if ($password !== $confirm_password) $errors[] = "Mật khẩu xác nhận không khớp";
if (empty($phone)) $errors[] = "Số điện thoại không được để trống";
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = "Email không hợp lệ";
if (empty($errors)) {
include_once "./Models/User.php";
$userModel = new User();
// Kiểm tra email đã tồn tại chưa
$existingUser = $userModel->getByEmail($email);
if ($existingUser) {
$errors[] = "Email đã được sử dụng";
} else {
// Hash password
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
// Đăng ký user
$result = $userModel->create($name, $email, $hashed_password, $phone, $role);
if ($result) {
$_SESSION['success'] = "Đăng ký thành công! Vui lòng đăng nhập.";
header("Location: ?ctrl=user&act=login");
exit();
} else {
$errors[] = "Có lỗi xảy ra khi đăng ký";
}
}
}
}
include_once "./Views/user_register.php";
}
Chú ý: khối trên là thân của class. File hoàn chỉnh phải có thêm phần mở đầu và phần đóng:
<?php class UserController { // ...khối code trên đặt ở đây... }Bài 15 sẽ thêm
login()vàlogout(), Bài 16 thêm bốn phương thức nữa. Tất cả chèn vào trước dấu}cuối cùng.
Phương thức này dài 48 dòng và là ví dụ hoàn chỉnh nhất về luồng form ở mục 2.5. Ta đọc theo bốn tầng lồng nhau.
Tầng 1 — if ($_SERVER['REQUEST_METHOD'] === 'POST')
Phân biệt "mở trang" với "bấm nút gửi".
Tầng 2 — sáu dòng kiểm tra
Mỗi dòng một quy tắc, đều dùng cú pháp if (điều kiện) câu lệnh; không ngoặc nhọn.
Thứ tự các quy tắc không quan trọng vì tất cả đều chạy — người dùng thấy mọi lỗi cùng lúc.
Tầng 3 — if (empty($errors))
Chỉ khi qua hết vòng kiểm tra cơ bản mới đụng tới database. Đây là thứ tự đúng: kiểm tra rẻ trước, kiểm tra tốn kém sau.
Tầng 4 — if ($existingUser) ... else ...
Nhánh else mới thực sự tạo tài khoản: băm mật khẩu, gọi create(), ghi flash message, chuyển hướng.
Ba dòng cuối của luồng thành công:
$_SESSION['success'] = "Đăng ký thành công! Vui lòng đăng nhập.";
header("Location: ?ctrl=user&act=login");
exit();
Đúng mẫu flash message bạn học ở Bài 5 mục 2.5, cộng với exit() bắt buộc ở Bài 4 mục 2.8.
Thông báo này sẽ hiện trên trang đăng nhập — và trang đó có khối hiển thị flash, khác với trang danh sách khóa học ở Bài 13 bài tập 3.
3.3. Tạo View
📁
Views/user_register.php— tạo mới
<main class="register-page">
<div class="register-container">
<h2>Đăng ký tài khoản</h2>
<?php if (isset($_SESSION['error'])): ?>
<div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?= $_SESSION['error'] ?>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<?php if (isset($errors) && !empty($errors)): ?>
<div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?php foreach ($errors as $error): ?>
<div><?= $error ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="POST" class="register-form">
<div>
<label for="name">Họ và tên *</label>
<input type="text" id="name" name="name" value="<?= isset($_POST['name']) ? htmlspecialchars($_POST['name']) : '' ?>" required>
</div>
<div>
<label for="email">Email *</label>
<input type="email" id="email" name="email" value="<?= isset($_POST['email']) ? htmlspecialchars($_POST['email']) : '' ?>" required>
</div>
<div>
<label for="phone">Số điện thoại *</label>
<input type="tel" id="phone" name="phone" value="<?= isset($_POST['phone']) ? htmlspecialchars($_POST['phone']) : '' ?>" required>
</div>
<div>
<label for="password">Mật khẩu *</label>
<input type="password" id="password" name="password" required>
</div>
<div>
<label for="confirm_password">Xác nhận mật khẩu *</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit">Đăng ký</button>
</form>
<div style="margin-top: 20px; text-align: center;">
<p>Đã có tài khoản? <a href="?ctrl=user&act=login" style="color: #0868b4;">Đăng nhập ngay</a></p>
</div>
</div>
</main>
Hai khối hiển thị lỗi khác nhau:
Khối thứ nhất hiện $_SESSION['error'] — lỗi từ trang khác chuyển tới, có unset().
Khối thứ hai hiện mảng $errors — lỗi ngay trong lần gửi form này, không cần unset() vì biến thường tự biến mất khi trang kết thúc.
Điều kiện isset($errors) && !empty($errors) cần cả hai vế: isset() vì khi mở trang lần đầu biến $errors chưa tồn tại, !empty() vì mảng rỗng thì không có gì để hiện.
Ba ô có value= và hai ô mật khẩu không có — đúng nguyên tắc bạn học ở Bài 4 bài tập.
Thuộc tính required chỉ là lớp bảo vệ đầu tiên. Nó chặn ở trình duyệt cho tiện, nhưng ai cũng tắt được. Kiểm tra thật nằm ở Controller.
type="email" và type="tel" giúp điện thoại hiện bàn phím phù hợp và trình duyệt kiểm tra sơ bộ. Cũng chỉ là tiện ích, không phải bảo mật.
⚠️ Lưu ý: Chú ý sự thiếu nhất quán ngay trong file này. Các ô
value=cóhtmlspecialchars(), nhưng hai khối hiển thị lỗi thì dùng<?= $_SESSION['error'] ?>và<?= $error ?>không lọc.Hiện tại các thông báo lỗi đều do lập trình viên viết cứng nên an toàn. Nhưng ở
UserController::updateProfile()mà bạn sẽ viết ở Bài 16, có dòng$_SESSION['error'] = implode(", ", $errors);— và nếu sau này ai đó thêm một thông báo lỗi chứa dữ liệu người dùng, lỗ hổng mở ra ngay tại đây.Đây là lỗi số 10 trong Bài 22.
4. Chạy thử
Mở ?ctrl=user&act=register, hoặc bấm "Đăng ký" trên menu.
Thử 1 — Bỏ trống mọi ô. Trình duyệt chặn vì required. Muốn thử phần kiểm tra ở máy chủ, hãy bấm F12, chọn ô nhập, xóa thuộc tính required trong tab Elements, rồi bấm Đăng ký.
Kết quả: khung đỏ liệt kê năm lỗi cùng lúc:
Tên không được để trống
Email không được để trống
Mật khẩu không được để trống
Số điện thoại không được để trống
Email không hợp lệ
Chú ý hai lỗi cuối đều về email. Đây là điểm chưa hoàn thiện mà Bài 4 bài tập 1 đã bàn: thiếu điều kiện !empty($email) && trước khi kiểm định dạng.
Thử 2 — Mật khẩu không khớp. Điền đủ, nhưng hai ô mật khẩu khác nhau. Kết quả: Mật khẩu xác nhận không khớp. Và ba ô trên vẫn giữ nguyên dữ liệu bạn đã gõ.
Thử 3 — Email đã dùng. Điền email hhoang02052004@gmail.com. Kết quả: Email đã được sử dụng.
Thử 4 — Đăng ký thành công. Điền dữ liệu hợp lệ với email mới, ví dụ test@gmail.com và mật khẩu 123456.
Bạn được chuyển sang trang đăng nhập kèm khung xanh: Đăng ký thành công! Vui lòng đăng nhập.
Kiểm chứng trong database. Mở phpMyAdmin, bảng users, tab Browse, kéo xuống dòng cuối. Bạn thấy:
| Cột | Giá trị |
|---|---|
id |
36 |
name |
tên bạn gõ |
email |
test@gmail.com |
password |
$2y$10$... — 60 ký tự khó đọc |
role |
student |
Mật khẩu 123456 không nằm ở đâu trong database. Chỉ có chuỗi băm. Đây là điều bài học này muốn bạn thấy tận mắt.
Thử 5 — Đăng ký thêm một tài khoản nữa, cũng dùng mật khẩu 123456.
So sánh cột password của hai dòng — hai chuỗi hoàn toàn khác nhau, dù cùng một mật khẩu. Đó là muối ngẫu nhiên ở mục 2.3.
Thử 6 — Bấm F5 trên trang đăng nhập. Thông báo xanh biến mất, vì unset() đã xóa nó sau lần hiện đầu tiên.
5. Bài tập
Bài tập 1
Thêm hai quy tắc kiểm tra vào UserController::register():
- Mật khẩu phải có ít nhất 6 ký tự — dùng
strlen() - Số điện thoại phải đúng 10 chữ số — dùng
preg_match('/^[0-9]{10}$/', $phone)
Cả hai chỉ kiểm tra khi ô đó không rỗng, để tránh hiện hai lỗi cho cùng một ô.
Bài tập 2
Thêm vào Models/User.php phương thức getByPhone($phone), rồi dùng nó trong Controller để chặn số điện thoại trùng.
Thông báo lỗi: Số điện thoại đã được sử dụng.
Bài tập 3
Tạo hoc/bai14.php để tự chứng minh cách password_hash hoạt động:
- Gọi
password_hash("12345", PASSWORD_DEFAULT)ba lần, in cả ba chuỗi ra - Với từng chuỗi, gọi
password_verify("12345", $chuoi)và in kết quả - Gọi
password_verify("sai_roi", $chuoi)với chuỗi đầu tiên và in kết quả
Sau đó trả lời: ba chuỗi khác nhau, vậy vì sao password_verify vẫn xác nhận đúng cả ba?
6. Đáp án
Đáp án bài tập 1
📄 Controllers/UserController.php — thêm sau các dòng kiểm tra hiện có
if (!empty($password) && strlen($password) < 6) $errors[] = "Mật khẩu phải có ít nhất 6 ký tự";
if (!empty($phone) && !preg_match('/^[0-9]{10}$/', $phone)) $errors[] = "Số điện thoại phải có đúng 10 chữ số";
Về biểu thức chính quy /^[0-9]{10}$/:
| Phần | Nghĩa |
|---|---|
/ / |
Dấu mở và đóng biểu thức |
^ |
Bắt đầu chuỗi |
[0-9] |
Một ký tự từ 0 tới 9 |
{10} |
Lặp đúng 10 lần |
$ |
Kết thúc chuỗi |
Hai dấu ^ và $ rất quan trọng. Không có chúng, chuỗi abc0941280072xyz cũng khớp vì nó chứa 10 chữ số liên tiếp.
Vì sao dùng strlen() mà không phải mb_strlen()? strlen() đếm byte, mb_strlen() đếm ký tự. Với mật khẩu chỉ có chữ và số thì hai hàm cho kết quả giống nhau. Nhưng nếu người dùng đặt mật khẩu tiếng Việt có dấu, một ký tự chiếm 3 byte, và strlen() sẽ đếm nhiều hơn thực tế.
Với mật khẩu thì đếm byte lại hợp lý hơn, vì độ mạnh của mật khẩu phụ thuộc vào số byte chứ không phải số ký tự nhìn thấy. Nên strlen() ở đây là lựa chọn đúng.
Còn với tên người dùng thì phải dùng mb_strlen(), nếu không "Nguyễn Văn A" sẽ bị tính thành 16 byte thay vì 12 ký tự.
Đáp án bài tập 2
📄 Models/User.php
function getByPhone($phone)
{
$sql = "SELECT * FROM users WHERE phone=?";
return $this->db->queryOne($sql, $phone);
}
📄 Controllers/UserController.php — sửa khối kiểm tra trùng
if (empty($errors)) {
include_once "./Models/User.php";
$userModel = new User();
// Kiểm tra email đã tồn tại chưa
$existingUser = $userModel->getByEmail($email);
$existingPhone = $userModel->getByPhone($phone);
if ($existingUser) {
$errors[] = "Email đã được sử dụng";
} elseif ($existingPhone) {
$errors[] = "Số điện thoại đã được sử dụng";
} else {
// ...phần còn lại giữ nguyên...
}
}
Một điểm đáng suy nghĩ: dùng elseif nghĩa là nếu email đã trùng thì không kiểm tra số điện thoại nữa. Người dùng phải sửa email, gửi lại, rồi mới biết số điện thoại cũng trùng.
Cách thân thiện hơn là kiểm tra cả hai rồi báo cùng lúc:
if ($existingUser) {
$errors[] = "Email đã được sử dụng";
}
if ($existingPhone) {
$errors[] = "Số điện thoại đã được sử dụng";
}
if (empty($errors)) {
// ...tạo tài khoản...
}
Đây đúng tinh thần của mẫu gom lỗi: báo hết mọi vấn đề trong một lần, đừng bắt người dùng sửa từng cái một.
Đáp án bài tập 3
📄 hoc/bai14.php
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
$matKhau = "12345";
echo "<h2>Băm cùng một mật khẩu ba lần</h2>";
$hash1 = password_hash($matKhau, PASSWORD_DEFAULT);
$hash2 = password_hash($matKhau, PASSWORD_DEFAULT);
$hash3 = password_hash($matKhau, PASSWORD_DEFAULT);
echo "<p><code>$hash1</code></p>";
echo "<p><code>$hash2</code></p>";
echo "<p><code>$hash3</code></p>";
echo "<p>Ba chuỗi có giống nhau không? ";
echo ($hash1 === $hash2 && $hash2 === $hash3) ? "CÓ" : "<strong>KHÔNG</strong>";
echo "</p>";
echo "<h2>Kiểm tra bằng password_verify</h2>";
echo "<p>verify('12345', hash1) = " . (password_verify($matKhau, $hash1) ? "ĐÚNG" : "SAI") . "</p>";
echo "<p>verify('12345', hash2) = " . (password_verify($matKhau, $hash2) ? "ĐÚNG" : "SAI") . "</p>";
echo "<p>verify('12345', hash3) = " . (password_verify($matKhau, $hash3) ? "ĐÚNG" : "SAI") . "</p>";
echo "<h2>Thử mật khẩu sai</h2>";
echo "<p>verify('sai_roi', hash1) = " . (password_verify("sai_roi", $hash1) ? "ĐÚNG" : "SAI") . "</p>";
echo "<h2>Mổ xẻ cấu trúc hash1</h2>";
echo "<p>Độ dài: " . strlen($hash1) . " ký tự</p>";
echo "<p>Thuật toán và hệ số: <code>" . substr($hash1, 0, 7) . "</code></p>";
echo "<p>Muối (22 ký tự): <code>" . substr($hash1, 7, 22) . "</code></p>";
echo "<p>Phần băm (31 ký tự): <code>" . substr($hash1, 29) . "</code></p>";
?>
Kết quả: ba chuỗi hoàn toàn khác nhau, nhưng password_verify xác nhận ĐÚNG với cả ba, và SAI với mật khẩu sai.
Trả lời câu hỏi: vì sao password_verify vẫn đúng?
Vì muối được lưu ngay bên trong chuỗi băm, không giấu ở đâu cả.
Khi bạn gọi password_verify("12345", $hash1), hàm này làm bốn bước:
- Đọc phần đầu chuỗi để biết thuật toán là bcrypt và hệ số công việc là 10.
- Cắt lấy 22 ký tự muối — chính là phần bạn in ra ở mục cuối của bài tập.
- Băm lại
"12345"bằng đúng muối đó với đúng hệ số đó. - So kết quả với 31 ký tự cuối của chuỗi gốc. Khớp thì trả về
true.
Với $hash2, muối khác nên bước 3 cho ra kết quả khác — nhưng nó cũng được so với phần băm của $hash2, và vẫn khớp.
Với mật khẩu "sai_roi", dù muối đúng thì kết quả băm vẫn khác hẳn, nên trả về false.
Vì sao lưu muối công khai vẫn an toàn?
Vì mục đích của muối không phải là bí mật. Nó chỉ để bảo đảm hai người dùng cùng mật khẩu vẫn có chuỗi băm khác nhau, khiến rainbow table dựng sẵn trở nên vô dụng.
Kẻ tấn công có chuỗi băm và biết muối thì vẫn phải thử từng mật khẩu một, mỗi lần thử tốn 2¹⁰ vòng lặp. Với một danh sách 35 người dùng, họ phải làm 35 lần công việc đó thay vì tra một bảng chung. Đó chính là điều muối mang lại.
Một quy tắc tuyệt đối: không bao giờ tự viết hàm băm mật khẩu bằng
md5()haysha1(). Hai hàm đó được thiết kế để chạy nhanh, mà nhanh chính là điều bạn không muốn ở đây — máy tính hiện đại thử được hàng tỷ chuỗi md5 mỗi giây.Luôn dùng
password_hash()vàpassword_verify(). Chúng có sẵn, đúng chuẩn, và tự cập nhật khi PHP nâng cấp thuật toán.
➡️ Bài tiếp theo: Bài 15 — Đăng nhập, đăng xuất, phân quyền hiển thị
⬅️ Về mục lục · Bài trước
All rights reserved