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 成功,数据才真正写入。

📖 小节

📝 作业

  1. 写一个简易转账系统:创建 accounts 表,用事务实现 A 向 B 转账(同时更新两个余额,任意一步失败则回滚)。
  2. 故意写一个不用预处理语句的登录表单,输入 ' OR '1'='1' -- 观察 SQL 注入的效果。然后改成预处理语句,体验安全改造。
  3. 完善用户注册和登录系统:注册用 password_hash,登录用 password_verify,加上 password_needs_rehash 检测。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏