2025 PHP7/8 实战入门:15 天精通现代 Web 开发——第 14 课:安全开发实践

第 14 课:安全开发实践

一、学习目标

  1. 掌握 Web 开发中常见安全漏洞(XSS、CSRF、SQL 注入等)的防御手段
  2. 熟练运用 PHP7/8 安全特性(密码哈希、输入过滤等)保护应用
  3. 理解敏感数据保护(加密、脱敏)和文件上传安全的核心要点
  4. 能够制定符合生产环境要求的 PHP 安全开发规范

二、核心知识点

(一)常见安全漏洞与防御
  1. XSS(跨站脚本攻击)

    • 原理:攻击者注入恶意 HTML/JS 代码,浏览器执行后窃取用户 Cookie、伪造操作等
    • 分类:
      • 存储型 XSS:恶意代码存入数据库(如评论区、用户资料),所有访问者都会执行
      • 反射型 XSS:恶意代码通过 URL 参数注入(如搜索框),仅攻击者诱导的用户执行
    • 防御手段:
      • 输入过滤:过滤 HTML 标签、JS 事件(如onclickonload
      • 输出转义:用htmlspecialchars()转义特殊字符(<&lt;>&gt;等)
      • CSP(内容安全策略):通过 HTTP 头限制资源加载来源

    示例(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>";
    // 输出:&lt;script&gt;alert(&quot;XSS攻击&quot;)&lt;/script&gt;(浏览器不会执行)
    
    // 3. 设置CSP头(防御存储型XSS)
    header("Content-Security-Policy: default-src 'self'; script-src 'self'");
    // 含义:仅允许加载当前域名的资源,仅允许执行当前域名的JS
    ?>
    
  2. CSRF(跨站请求伪造)

    • 原理:攻击者诱导用户在已登录状态下访问恶意网站,利用用户 Cookie 伪造合法请求(如转账、修改密码)
    • 防御手段:
      • CSRF Token:生成随机令牌,嵌入表单和 Session,提交时验证令牌一致性
      • 同源检测:验证RefererOrigin请求头(辅助手段,可能被绕过)
      • 验证码:敏感操作(如支付)添加验证码,强制用户交互

    示例(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>
    
  3. SQL 注入防御(补充)

    • 核心原则:所有用户输入都不可信,必须通过参数绑定处理
    • 防御手段:
      • 优先使用 PDO 预处理语句(prepare+execute),强制参数绑定
      • 避免直接拼接 SQL 字符串(即使过滤也可能被绕过)
      • 使用 ORM 框架(如 Eloquent、Doctrine),进一步减少手动 SQL 编写

    示例(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 "用户不存在";
    }
    ?>
    
(二)密码安全
  1. 密码哈希(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>";
    }
    ?>
    
  2. 密码策略

    • 强制密码复杂度:长度≥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>";
        }
    }
    ?>
    
(三)敏感数据保护
  1. 数据加密与解密

    • 适用场景:用户手机号、身份证号、银行卡号等敏感信息
    • 推荐算法: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
    ?>
    
  2. 数据脱敏

    • 适用场景:无需完整展示敏感数据的场景(如列表页显示手机号、订单页显示银行卡号)
    • 脱敏规则:
      • 手机号:隐藏中间 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
    ?>
    
(四)文件上传安全(补充)
  1. 完整安全校验流程

    • 验证文件类型: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表单)
    ?>
    
  2. 上传目录安全配置

    • 在上传目录创建index.html(空白文件),防止目录遍历
    • Nginx 配置禁止执行上传目录的 PHP 文件:

      nginx

      location ~ /uploads/.*\.php$ {
          deny all; # 禁止访问上传目录的PHP文件
      }
      
    • 上传目录权限设置为0755(所有者可读写执行,其他只读执行),文件权限0644(仅读写)

三、注意事项

  1. 错误处理与日志安全

    • 生产环境禁用display_errorsphp.inidisplay_errors = Off),避免泄露系统信息
    • 启用log_errorslog_errors = On),将错误日志写入文件(如error_log = /var/log/php/error.log
    • 安全日志单独记录:用户登录、敏感操作(如支付、密码修改)的日志需包含时间、用户 ID、IP 地址、操作内容
  2. 服务器配置安全

    • 禁用危险函数:php.inidisable_functions = exec,passthru,shell_exec,system(禁止执行系统命令)
    • 限制open_basediropen_basedir = /var/www/html:/tmp(仅允许 PHP 访问指定目录,防止目录遍历)
    • 启用suPHPPHP-FPMsecurity.limit_extensions(仅允许执行.php扩展名文件)
  3. 第三方依赖安全

    • 定期更新框架和扩展(如 Laravel、ThinkPHP),修复已知安全漏洞
    • 使用composer audit检查项目依赖的安全漏洞
    • 避免使用来源不明的第三方代码(如盗版插件、未审计的类库)

四、实战练习

  1. 创建day14文件夹,新建secure_login.php文件:

    • 实现一个安全的用户登录系统,包含:
      • 登录表单:用户名、密码输入框,验证码(用gd库生成简单图形验证码)
      • 安全校验:
        • 用户名 / 密码非空验证,密码复杂度验证(登录时验证哈希,注册时验证复杂度)
        • CSRF Token 防护(表单嵌入令牌,提交时验证)
        • 登录失败限制:5 次失败后锁定账号 15 分钟(用 Session 或 Redis 记录失败次数和时间)
      • 安全输出:所有用户输入内容用htmlspecialchars转义,防止 XSS
      • 日志记录:登录成功 / 失败日志(包含时间、用户名、IP、结果)写入logs/login.log
  2. 新建secure_upload.php文件:

    • 实现一个安全的图片上传系统,要求:
      • 仅允许上传 jpg、png、gif 格式图片,最大尺寸 1MB
      • 完整校验流程:MIME 类型(finfo_file)+ 扩展名 + 图片内容验证(imagecreatefromstring
      • 文件名处理:重命名为 “用户 ID_时间戳_随机数。扩展名”(模拟用户 ID 为 1001)
      • 存储安全:上传目录禁止 Web 访问执行 PHP(通过 Nginx 配置或.htaccess
      • 预览功能:上传成功后显示图片预览(用htmlspecialchars处理图片路径,防止 XSS)
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Anson Jiang

感谢客官老爷打赏的咖啡钱:-)

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值