2025 PHP7/8 实战入门:15 天精通现代 Web 开发——第 14 课:安全开发实践
第 14 课:安全开发实践
一、学习目标
- 掌握 Web 开发中常见安全漏洞(XSS、CSRF、SQL 注入等)的防御手段
- 熟练运用 PHP7/8 安全特性(密码哈希、输入过滤等)保护应用
- 理解敏感数据保护(加密、脱敏)和文件上传安全的核心要点
- 能够制定符合生产环境要求的 PHP 安全开发规范
二、核心知识点
(一)常见安全漏洞与防御
XSS(跨站脚本攻击)
- 原理:攻击者注入恶意 HTML/JS 代码,浏览器执行后窃取用户 Cookie、伪造操作等
- 分类:
- 存储型 XSS:恶意代码存入数据库(如评论区、用户资料),所有访问者都会执行
- 反射型 XSS:恶意代码通过 URL 参数注入(如搜索框),仅攻击者诱导的用户执行
- 防御手段:
- 输入过滤:过滤 HTML 标签、JS 事件(如
onclick
、onload
) - 输出转义:用
htmlspecialchars()
转义特殊字符(<
→<
,>
→>
等) - CSP(内容安全策略):通过 HTTP 头限制资源加载来源
- 输入过滤:过滤 HTML 标签、JS 事件(如
示例(XSS 防御实践):
<?php // 1. 输入过滤(过滤HTML标签) function filterXSS(string $input): string {// 方法1:用strip_tags过滤所有HTML标签(简单场景)$filtered = strip_tags($input);// 方法2:用HTML Purifier过滤(复杂场景,保留允许的标签)// require_once 'HTMLPurifier.auto.php';// $config = HTMLPurifier_Config::createDefault();// $purifier = new HTMLPurifier($config);// $filtered = $purifier->purify($input);return $filtered; }// 2. 输出转义(关键步骤,必须执行) $user_input = $_GET['content'] ?? '<script>alert("XSS攻击")</script>'; $filtered_input = filterXSS($user_input); $escaped_input = htmlspecialchars($filtered_input, ENT_QUOTES, 'UTF-8'); // ENT_QUOTES转义单双引号echo "安全输出:{$escaped_input}<br>"; // 输出:<script>alert("XSS攻击")</script>(浏览器不会执行)// 3. 设置CSP头(防御存储型XSS) header("Content-Security-Policy: default-src 'self'; script-src 'self'"); // 含义:仅允许加载当前域名的资源,仅允许执行当前域名的JS ?>
CSRF(跨站请求伪造)
- 原理:攻击者诱导用户在已登录状态下访问恶意网站,利用用户 Cookie 伪造合法请求(如转账、修改密码)
- 防御手段:
- CSRF Token:生成随机令牌,嵌入表单和 Session,提交时验证令牌一致性
- 同源检测:验证
Referer
或Origin
请求头(辅助手段,可能被绕过) - 验证码:敏感操作(如支付)添加验证码,强制用户交互
示例(CSRF Token 实现):
<?php session_start(['cookie_httponly' => true,'use_strict_mode' => true ]);// 1. 生成CSRF Token(存储到Session) function generateCsrfToken(): string {$token = bin2hex(random_bytes(32)); // 生成32字节随机字符串$_SESSION['csrf_token'] = $token;$_SESSION['csrf_token_expire'] = time() + 3600; // 1小时有效期return $token; }// 2. 验证CSRF Token function validateCsrfToken(string $token): bool {if (!isset($_SESSION['csrf_token'], $_SESSION['csrf_token_expire'])) {return false;}// 验证令牌有效性和过期时间return $token === $_SESSION['csrf_token'] && time() < $_SESSION['csrf_token_expire']; }// 3. 业务逻辑(修改密码,敏感操作) $message = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') {// 验证CSRF Token$submitted_token = $_POST['csrf_token'] ?? '';if (!validateCsrfToken($submitted_token)) {die("CSRF攻击防护:无效的请求令牌");}// 验证通过,执行修改密码逻辑(省略)$message = "密码修改成功!";// 消耗令牌(防止重复提交)unset($_SESSION['csrf_token'], $_SESSION['csrf_token_expire']); }// 生成新的CSRF Token $csrf_token = generateCsrfToken(); ?><!-- 4. 表单中嵌入CSRF Token --> <form method="post"><input type="hidden" name="csrf_token" value="<?= $csrf_token ?>"><div><label>新密码:</label><input type="password" name="new_password" required></div><div><label>确认密码:</label><input type="password" name="confirm_password" required></div><div><input type="submit" value="修改密码"></div><?php if ($message): ?><p style="color: green;"><?= $message ?></p><?php endif; ?> </form>
SQL 注入防御(补充)
- 核心原则:所有用户输入都不可信,必须通过参数绑定处理
- 防御手段:
- 优先使用 PDO 预处理语句(
prepare
+execute
),强制参数绑定 - 避免直接拼接 SQL 字符串(即使过滤也可能被绕过)
- 使用 ORM 框架(如 Eloquent、Doctrine),进一步减少手动 SQL 编写
- 优先使用 PDO 预处理语句(
示例(PDO 预处理防注入):
<?php $pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,PDO::ATTR_EMULATE_PREPARES => false // 禁用模拟预处理,强制数据库原生预处理 ]);// 危险写法:直接拼接用户输入(易被SQL注入) $user_id = $_GET['id'] ?? 1; $sql_bad = "SELECT * FROM users WHERE id = {$user_id}"; // 若user_id为"1 OR 1=1",则查询所有用户// 安全写法:PDO预处理 $sql_good = "SELECT username, email FROM users WHERE id = :id"; $stmt = $pdo->prepare($sql_good); $stmt->execute(['id' => $user_id]); // 参数绑定,自动转义 $user = $stmt->fetch();if ($user) {echo "用户名:{$user['username']},邮箱:{$user['email']}"; } else {echo "用户不存在"; } ?>
(二)密码安全
密码哈希(PHP5.5+)
- 核心函数:
password_hash()
:生成密码哈希(自动生成随机盐值,无需手动处理)password_verify()
:验证密码与哈希是否匹配password_needs_rehash()
:检查哈希是否需要重新生成(如算法升级)
- 推荐算法:
PASSWORD_DEFAULT
(自动使用当前最优算法,PHP7 默认bcrypt
,PHP8 默认Argon2id
)
示例(密码哈希实践):
<?php // 1. 密码加密(注册时) $plain_password = 'user123456'; // 用户输入的明文密码 $hash_options = ['cost' => 12, // 计算成本(10-12为宜,越高越安全但耗时更长)// 'algorithm' => PASSWORD_ARGON2ID // PHP7.2+支持Argon2id算法(更安全) ]; $password_hash = password_hash($plain_password, PASSWORD_DEFAULT, $hash_options); echo "密码哈希:{$password_hash}<br>"; // 格式:$2y$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx// 2. 密码验证(登录时) $login_password = 'user123456'; // 用户登录输入的密码 if (password_verify($login_password, $password_hash)) {echo "密码验证成功!<br>";// 3. 检查哈希是否需要升级(如算法或成本变化)if (password_needs_rehash($password_hash, PASSWORD_DEFAULT, $hash_options)) {// 重新生成哈希并更新到数据库$new_hash = password_hash($login_password, PASSWORD_DEFAULT, $hash_options);echo "密码哈希已升级:{$new_hash}<br>";// $pdo->prepare("UPDATE users SET password = :hash WHERE id = :id")->execute(['hash' => $new_hash, 'id' => $user_id]);} } else {echo "密码验证失败!<br>"; } ?>
- 核心函数:
密码策略
- 强制密码复杂度:长度≥8 位,包含大小写字母、数字、特殊字符
- 防止密码泄露:
- 禁止明文存储密码(即使加密也不行,必须哈希)
- 定期提醒用户修改密码(如 90 天)
- 限制登录失败次数(如 5 次失败后锁定账号 15 分钟)
示例(密码复杂度验证):
<?php // 验证密码复杂度 function validatePasswordStrength(string $password): array {$errors = [];// 长度≥8位if (strlen($password) < 8) {$errors[] = "密码长度必须至少8位";}// 包含大写字母if (!preg_match('/[A-Z]/', $password)) {$errors[] = "密码必须包含至少一个大写字母";}// 包含小写字母if (!preg_match('/[a-z]/', $password)) {$errors[] = "密码必须包含至少一个小写字母";}// 包含数字if (!preg_match('/\d/', $password)) {$errors[] = "密码必须包含至少一个数字";}// 包含特殊字符if (!preg_match('/[!@#$%^&*()]/', $password)) {$errors[] = "密码必须包含至少一个特殊字符(!@#$%^&*())";}return $errors; }// 测试 $test_password = 'User123!'; $errors = validatePasswordStrength($test_password); if (empty($errors)) {echo "密码符合复杂度要求"; } else {echo "密码不符合要求:<br>";foreach ($errors as $error) {echo "- {$error}<br>";} } ?>
(三)敏感数据保护
数据加密与解密
- 适用场景:用户手机号、身份证号、银行卡号等敏感信息
- 推荐算法:AES-256-CBC(对称加密,速度快,适合大量数据)
- 注意:加密密钥必须安全存储(如环境变量、配置文件权限控制),避免硬编码
示例(AES 加密实践):
<?php // 加密配置(生产环境密钥需从安全渠道获取) define('ENCRYPT_KEY', 'your-32-byte-secure-key-here-123'); // 32字节(256位)密钥 define('ENCRYPT_IV', random_bytes(openssl_cipher_iv_length('aes-256-cbc'))); // 随机IV(每次加密生成,需与密文一起存储)// 1. 加密敏感数据 function encryptData(string $data): string {$iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc'));$encrypted = openssl_encrypt($data, 'aes-256-cbc', ENCRYPT_KEY, OPENSSL_RAW_DATA, $iv);// 拼接IV和密文(IV无需保密,需与密文一起存储)return base64_encode($iv . $encrypted); }// 2. 解密敏感数据 function decryptData(string $encrypted_data): ?string {try {$decoded = base64_decode($encrypted_data);$iv_length = openssl_cipher_iv_length('aes-256-cbc');$iv = substr($decoded, 0, $iv_length);$encrypted = substr($decoded, $iv_length);return openssl_decrypt($encrypted, 'aes-256-cbc', ENCRYPT_KEY, OPENSSL_RAW_DATA, $iv);} catch (Exception $e) {return null;} }// 测试 $phone = '13812345678'; // 敏感数据(手机号) $encrypted_phone = encryptData($phone); echo "加密后手机号:{$encrypted_phone}<br>";$decrypted_phone = decryptData($encrypted_phone); echo "解密后手机号:{$decrypted_phone}<br>"; // 输出:13812345678 ?>
数据脱敏
- 适用场景:无需完整展示敏感数据的场景(如列表页显示手机号、订单页显示银行卡号)
- 脱敏规则:
- 手机号:隐藏中间 4 位(138****5678)
- 身份证号:隐藏中间 8 位(110101********1234)
- 银行卡号:隐藏中间 8 位(622848********1234)
示例(数据脱敏函数):
<?php // 1. 手机号脱敏 function maskPhone(string $phone): string {if (preg_match('/^1\d{10}$/', $phone)) {return substr($phone, 0, 3) . '****' . substr($phone, 7);}return $phone; }// 2. 身份证号脱敏 function maskIdCard(string $id_card): string {if (preg_match('/^\d{18}$/', $id_card)) {return substr($id_card, 0, 6) . '********' . substr($id_card, 14);}return $id_card; }// 3. 银行卡号脱敏 function maskBankCard(string $bank_card): string {if (preg_match('/^\d{16,19}$/', $bank_card)) {$len = strlen($bank_card);return substr($bank_card, 0, 6) . str_repeat('*', $len - 10) . substr($bank_card, -4);}return $bank_card; }// 测试 echo "脱敏手机号:" . maskPhone('13812345678') . "<br>"; // 138****5678 echo "脱敏身份证:" . maskIdCard('110101199001011234') . "<br>"; // 110101********1234 echo "脱敏银行卡:" . maskBankCard('6228480402561234567') . "<br>"; // 622848********567 ?>
(四)文件上传安全(补充)
完整安全校验流程
- 验证文件类型:MIME 类型(
finfo_file
)+ 扩展名双重校验 - 验证文件大小:限制上传文件最大尺寸(如 2MB)
- 验证文件内容:图片文件用 GD 库重新生成(清除恶意代码),非图片文件禁止执行权限
- 处理文件名:重命名文件(避免路径遍历攻击),存储路径隔离(禁止 Web 访问上传目录执行 PHP)
示例(文件上传安全增强):
<?php $upload_errors = []; $allowed_mimes = ['image/jpeg' => ['jpg', 'jpeg'],'image/png' => ['png'],'application/pdf' => ['pdf'] ]; $max_size = 2 * 1024 * 1024; // 2MB $upload_dir = __DIR__ . '/uploads/'; // 上传目录(禁止Web访问执行PHP)if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {$file = $_FILES['file'];// 1. 验证上传错误if ($file['error'] !== UPLOAD_ERR_OK) {$upload_errors[] = "上传错误:" . $file['error'];goto show_result;}// 2. 验证文件大小if ($file['size'] > $max_size) {$upload_errors[] = "文件过大(最大支持2MB)";goto show_result;}// 3. 验证文件类型(MIME+扩展名)$finfo = new finfo(FILEINFO_MIME_TYPE);$file_mime = $finfo->file($file['tmp_name']);$file_ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));if (!isset($allowed_mimes[$file_mime]) || !in_array($file_ext, $allowed_mimes[$file_mime])) {$upload_errors[] = "不允许的文件类型(仅支持jpg/png/pdf)";goto show_result;}// 4. 验证文件内容(图片重新生成)if (strpos($file_mime, 'image/') === 0) {$image = @imagecreatefromstring(file_get_contents($file['tmp_name']));if (!$image) {$upload_errors[] = "图片文件损坏或不是有效图片";goto show_result;}// 重新生成图片(清除恶意代码)$new_image_path = $upload_dir . 'img_' . uniqid() . '.' . $file_ext;switch ($file_ext) {case 'jpg':case 'jpeg':imagejpeg($image, $new_image_path, 90);break;case 'png':imagepng($image, $new_image_path);break;}imagedestroy($image);} else {// 非图片文件(如PDF),重命名并设置禁止执行权限$new_image_path = $upload_dir . 'file_' . uniqid() . '.' . $file_ext;move_uploaded_file($file['tmp_name'], $new_image_path);chmod($new_image_path, 0644); // 仅读写权限,无执行权限}$upload_errors[] = "上传成功!文件路径:{$new_image_path}"; }show_result: // 输出结果(省略HTML表单) ?>
- 验证文件类型:MIME 类型(
上传目录安全配置
- 在上传目录创建
index.html
(空白文件),防止目录遍历 - Nginx 配置禁止执行上传目录的 PHP 文件:
nginx
location ~ /uploads/.*\.php$ {deny all; # 禁止访问上传目录的PHP文件 }
- 上传目录权限设置为
0755
(所有者可读写执行,其他只读执行),文件权限0644
(仅读写)
- 在上传目录创建
三、注意事项
错误处理与日志安全
- 生产环境禁用
display_errors
(php.ini
中display_errors = Off
),避免泄露系统信息 - 启用
log_errors
(log_errors = On
),将错误日志写入文件(如error_log = /var/log/php/error.log
) - 安全日志单独记录:用户登录、敏感操作(如支付、密码修改)的日志需包含时间、用户 ID、IP 地址、操作内容
- 生产环境禁用
服务器配置安全
- 禁用危险函数:
php.ini
中disable_functions = exec,passthru,shell_exec,system
(禁止执行系统命令) - 限制
open_basedir
:open_basedir = /var/www/html:/tmp
(仅允许 PHP 访问指定目录,防止目录遍历) - 启用
suPHP
或PHP-FPM
的security.limit_extensions
(仅允许执行.php
扩展名文件)
- 禁用危险函数:
第三方依赖安全
- 定期更新框架和扩展(如 Laravel、ThinkPHP),修复已知安全漏洞
- 使用
composer audit
检查项目依赖的安全漏洞 - 避免使用来源不明的第三方代码(如盗版插件、未审计的类库)
四、实战练习
创建
day14
文件夹,新建secure_login.php
文件:- 实现一个安全的用户登录系统,包含:
- 登录表单:用户名、密码输入框,验证码(用
gd
库生成简单图形验证码) - 安全校验:
- 用户名 / 密码非空验证,密码复杂度验证(登录时验证哈希,注册时验证复杂度)
- CSRF Token 防护(表单嵌入令牌,提交时验证)
- 登录失败限制:5 次失败后锁定账号 15 分钟(用 Session 或 Redis 记录失败次数和时间)
- 安全输出:所有用户输入内容用
htmlspecialchars
转义,防止 XSS - 日志记录:登录成功 / 失败日志(包含时间、用户名、IP、结果)写入
logs/login.log
- 登录表单:用户名、密码输入框,验证码(用
- 实现一个安全的用户登录系统,包含:
新建
secure_upload.php
文件:- 实现一个安全的图片上传系统,要求:
- 仅允许上传 jpg、png、gif 格式图片,最大尺寸 1MB
- 完整校验流程:MIME 类型(
finfo_file
)+ 扩展名 + 图片内容验证(imagecreatefromstring
) - 文件名处理:重命名为 “用户 ID_时间戳_随机数。扩展名”(模拟用户 ID 为 1001)
- 存储安全:上传目录禁止 Web 访问执行 PHP(通过 Nginx 配置或
.htaccess
) - 预览功能:上传成功后显示图片预览(用
htmlspecialchars
处理图片路径,防止 XSS)
- 实现一个安全的图片上传系统,要求: