PHP: PDO进阶与安全
上节课你会用 PDO 做基本 CRUD 了。这节课拔高——事务让你的操作要么全成功要么全失败,密码哈希让数据库泄露也不怕,还有 SQL 注入的现场演示让你毕生难忘。
1. bindValue vs bindParam
PHP
<?php
$stmt = $pdo->prepare("INSERT INTO users (username, age) VALUES (:name, :age)");
// bindValue:绑定时就确定值(传值)
$name = '小明';
$stmt->bindValue(':name', $name, PDO::PARAM_STR);
// bindParam:绑定变量引用,execute() 时才取值
$age = 25;
$stmt->bindParam(':age', $age, PDO::PARAM_INT);
// 关键区别:
$name = '小红'; // bindValue 已绑定"小明",改 $name 不影响
$age = 30; // bindParam 引用 $age,execute 时会用 30
$stmt->execute(); // INSERT: name='小明', age=30
?>
bindValue |
bindParam |
|
|---|---|---|
| 绑定时机 | 调用时立即确定值 | execute() 时才取值 |
| 参数 | 可以是字面量/表达式 | 必须是变量 |
| 适用 | 大多数场景 | 需要在循环中反复执行 |
PHP
<?php
// bindParam 在循环中发挥价值
$stmt = $pdo->prepare("INSERT INTO logs (message) VALUES (:msg)");
$stmt->bindParam(':msg', $msg);
foreach (['启动服务', '用户登录', '处理请求', '服务关闭'] as $msg) {
$stmt->execute(); // 每次 execute 取 $msg 的当前值
}
?>
💡 提示: 大多数场景用
execute(['key' => $val]) 就够了,不需要 bindValue/bindParam。只有需要明确指定类型(PDO::PARAM_INT/PDO::PARAM_BOOL)或循环执行时才用 bind。
2. 事务处理(Transaction)
转账场景:A 扣钱、B 加钱——两个操作必须同时成功,要么同时失败:
PHP
<?php
// 创建测试表
// CREATE TABLE accounts (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), balance DECIMAL(10,2));
try {
$pdo->beginTransaction();
// 小明的账户减 200
$stmt = $pdo->prepare("UPDATE accounts SET balance = balance - :amount WHERE name = :name");
$stmt->execute(['amount' => 200, 'name' => '小明']);
// 小红的账户加 200
$stmt->execute(['amount' => 200, 'name' => '小红']);
$pdo->commit();
echo "转账成功!小明 → 小红 200元";
} catch (Exception $e) {
$pdo->rollBack();
echo "转账失败,已回滚:" . $e->getMessage();
}
?>
| 方法 | 作用 |
|---|---|
beginTransaction() |
开启事务 |
commit() |
提交(确认所有操作) |
rollBack() |
回滚(撤销所有操作) |
💡 提示: 事务的四条原则记在脑子里:要么全成功(原子性),数据保持一致(一致性),并发互不干扰(隔离性),成功后就永久保存(持久性)。简称 ACID。
3. SQL 注入现场演示
▶ 示例:SQL 注入攻击
PHP
<?php
// 假设登录验证(❌ 危险写法——绝对不要用)
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// ❌ 致命错误:直接拼接 SQL
$sql = "SELECT * FROM users WHERE username = '{$username}' AND password = '{$password}'";
echo "执行 SQL:{$sql}<br>";
// 攻击者在 username 输入框中输入:
// ' OR '1'='1' --
// 产生的 SQL:
// SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = ''
// 1=1 永远成立!-- 后面被注释掉!全部用户被查出!
// 更可怕的攻击:
// '; DROP TABLE users; --
// 产生的 SQL:
// SELECT * FROM users WHERE username = ''; DROP TABLE users; --' AND password = ''
// 整张表被删除!
?>
▶ 示例:安全的预处理防注入
PHP
<?php
// ✅ 安全写法——用预处理
$sql = "SELECT * FROM users WHERE username = :username AND password = :password";
$stmt = $pdo->prepare($sql);
$stmt->execute([
'username' => $_POST['username'] ?? '',
'password' => $_POST['password'] ?? '',
]);
$user = $stmt->fetch();
if ($user) {
echo "登录成功!";
} else {
echo "用户名或密码错误";
}
// 攻击者输入 ' OR '1'='1' -- 会被当作普通字符串匹配,不会执行
?>
🔥 易错: SQL 注入不是历史——OWASP(全球 Web 安全报告)至今仍把它列为 #1 安全风险。记住数据铁律:永远不要信任用户的输入,永远用预处理语句。
4. 密码哈希
明文存密码 = 自杀。密码哈希让数据库泄露也不怕:
PHP
<?php
// 注册时:对密码做哈希
$password = "mySecret123";
$hash = password_hash($password, PASSWORD_DEFAULT);
echo $hash;
// $2y$10$WzjCuq4qY8yXqUqMqHqRdOq...
// 每次哈希结果都不同(因为随机盐)
// 存入数据库
$stmt = $pdo->prepare("INSERT INTO users (username, password) VALUES (:u, :p)");
$stmt->execute(['u' => '小明', 'p' => $hash]);
// 登录时:验证密码
$inputPassword = "mySecret123";
$stmt = $pdo->prepare("SELECT password FROM users WHERE username = :u");
$stmt->execute(['u' => '小明']);
$user = $stmt->fetch();
if ($user && password_verify($inputPassword, $user['password'])) {
echo "✅ 密码正确,登录成功!";
} else {
echo "❌ 密码错误";
}
// 就算有人拿到数据库,看到的也是哈希值,无法直接登录
?>
| 算法 | 说明 | 推荐 |
|---|---|---|
PASSWORD_DEFAULT |
bcrypt(当前默认) | ✅ 推荐 |
PASSWORD_BCRYPT |
明确用 bcrypt | ✅ |
PASSWORD_ARGON2I |
Argon2(PHP 7.2+) | ✅ 更新更强 |
PASSWORD_ARGON2ID |
Argon2id(PHP 7.3+) | ✅ 最新最强 |
PHP
<?php
// 高级用法:加大成本因子
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// cost 越大越安全但也越慢(建议 10-12)
// 检查哈希是否需要重新哈希(算法升级后)
if (password_needs_rehash($hash, PASSWORD_DEFAULT, ['cost' => 12])) {
$newHash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
// 更新数据库中的哈希值
}
?>
5. 安全代码模板
PHP
<?php
class Auth {
private PDO $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
/**
* 用户注册
*/
public function register(string $username, string $email, string $password): array {
// 检查用户名是否已存在
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM users WHERE username = :u");
$stmt->execute(['u' => $username]);
if ($stmt->fetchColumn() > 0) {
return ['success' => false, 'message' => '用户名已存在'];
}
// 插入新用户
$hash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
$stmt = $this->pdo->prepare(
"INSERT INTO users (username, email, password) VALUES (:u, :e, :p)"
);
$stmt->execute(['u' => $username, 'e' => $email, 'p' => $hash]);
return ['success' => true, 'id' => $this->pdo->lastInsertId()];
}
/**
* 用户登录
*/
public function login(string $username, string $password): array {
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE username = :u");
$stmt->execute(['u' => $username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
return ['success' => false, 'message' => '用户名或密码错误'];
}
// 检查是否需要更新哈希
if (password_needs_rehash($user['password'], PASSWORD_DEFAULT, ['cost' => 12])) {
$newHash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
$stmt = $this->pdo->prepare("UPDATE users SET password = :p WHERE id = :id");
$stmt->execute(['p' => $newHash, 'id' => $user['id']]);
}
unset($user['password']); // 不要把密码哈希存 Session
return ['success' => true, 'user' => $user];
}
}
// 使用
$auth = new Auth($pdo);
// 注册
print_r($auth->register('测试用户', 'test@example.com', 'StrongPass123'));
// 登录
print_r($auth->login('测试用户', 'StrongPass123'));
?>
❓ 常见问题
Q 预处理语句中已有一层保护,还需要
htmlspecialchars 吗?A 需要!预处理防 SQL 注入(数据库层),
htmlspecialchars 防 XSS(输出层,第 18 课)。两者是不同层面的防线,缺一不可。Q
password_hash 每次结果不同,怎么验证?A 哈希值里包含了随机生成的"盐"。
password_verify 会从哈希值中提取盐,用同样的盐对输入的密码做哈希,然后比较。所以每次哈希结果不同,但验证总是对的。Q 事务中的操作如果一半成功一半失败会怎样?
A 不会出现"一半成功一半失败"——事务就是为此而生的。要么 commit 后全部生效,要么 rollback 后全部不生效。只有 commit 成功,数据才真正写入。
📖 小节
bindValue立即绑定值,bindParam绑定变量引用(execute 时取值)- 事务:
beginTransaction → execute → commit或rollBack - SQL 注入演示:
' OR '1'='1' --→ 永远用预处理防注入 password_hash()注册时哈希,password_verify()登录时验证password_needs_rehash()检查是否需要更新哈希算法- 预处理防 SQL 注入 ≠ 防 XSS,两者都是必须的防线
📝 作业
- 写一个简易转账系统:创建 accounts 表,用事务实现 A 向 B 转账(同时更新两个余额,任意一步失败则回滚)。
- 故意写一个不用预处理语句的登录表单,输入
' OR '1'='1' --观察 SQL 注入的效果。然后改成预处理语句,体验安全改造。 - 完善用户注册和登录系统:注册用
password_hash,登录用password_verify,加上password_needs_rehash检测。