如何在PHP中创建了一个SecurityService类来处理CSRF保护

287 阅读5分钟

PHP中的CSRF保护

跨站请求伪造被简写为CSRF。它是一种黑客攻击行为,黑客会逼迫你对你目前已经登录的网站做一些事情。反CSRF的实施减少了网站的脆弱性。

有了这种保护,网站就会拒绝那些发送没有CSRF标记或错误CSRF标记的请求的恶意访问。下图显示了针对CSRF攻击的用户请求验证。

csrf-cross-site-request-forgery

如果一个真正的用户用适当的令牌发布表单,服务器会处理该请求。否则,在没有CSRF令牌参数的情况下,它将拒绝。

本教程将展示一个带有CSRF保护的PHP联系表单的例子。有了这种保护,它将在处理请求之前确保其真实性。

此外,我们将在PHP中创建一个服务来处理针对CSRF攻击的安全验证。服务器将拒绝没有令牌或无效令牌的用户的请求。

目标

在本指南结束时,读者将了解以下内容。

  • CSRF攻击的概述。
  • 如何防止CSRF攻击。
  • PHP令牌管理会话活动的设置。
  • 在HTML联系表单中添加反CSRF令牌。

前提条件

要完成本教程,你将需要。

  • PHP的基础知识
  • 你选择的文本编辑器

案例分析

这段代码可以保护一个PHP联系表单免受CSRF攻击。首先,它创建了一个联系表单。然后,这个表单的后处理程序检查用户请求的CSRF攻击。最后,当登陆页面被加载时,PHP脚本会生成CSRF令牌。

这个令牌将是表单页脚中的一个隐藏字段。它也会在PHP会话中照顾到这个令牌。当表单提交时,PHP代码将检查CSRF令牌参数。如果会话中的令牌被发现,它将被验证。

如果用户发送的请求没有包括CSRF令牌,服务器将拒绝该请求。如果令牌与会话中的令牌不匹配,服务器也将拒绝该请求。

如果CSRF令牌被成功验证,服务器将向目标地址发送联系邮件。下图显示了这个例子的文件结构。

file-structure

让我们开始吧!

第1步:创建一个PHP会话并生成一个CSRF令牌

着陆页上的表单页脚脚本调用SecurityService 。这个类在PHP中生成一个CSRF令牌。它将令牌保存在一个PHP会话中,以便以后使用。它将帮助处理表单提交后的CSRF验证。

表单页脚是一个框架文件,用生成的令牌加载一个隐藏字段。例如,下面的代码摘录来自SecurityService.php 文件,生成了一个CSRF令牌。

本文的下一节将介绍服务类的完整代码。

  • SecurityService.php (生成CSRF令牌的代码)
/**
     * Generate, store, and return the CSRF token
     *
     * @return string[]
     */
    public function getCSRFToken()
    {
        if (empty($this->session[$this->sessionTokenLabel])) {
            $this->session[$this->sessionTokenLabel] = bin2hex(openssl_random_pseudo_bytes(32));
        }

        if ($this->hmac_ip !== false) {
            $token = $this->hMacWithIp($this->session[$this->sessionTokenLabel]);
        } else {
            $token = $this->session[$this->sessionTokenLabel];
        }
        return $token;
    }

第2步:用CSRF令牌渲染联系表格

这是一个带有姓名、电子邮件、主题和信息等常规字段的HTML联系表。此外,还有一个隐藏字段csrf-token,里面有生成的令牌。

提交动作在将参数发布到PHP之前处理jQuery表单验证。

客户端验证脚本处理提交时的基本验证。它对每个字段进行非空检查。

  • index.php (HTML模板)
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- title -->
    <title>PHP CSRF PROTECTION</title>
    <!-- Default stylesheet -->
    <link rel="stylesheet" href="css/style.css">
    <!-- JQuery -->
    <script src="vendor/jquery/jquery-3.2.1.min.js"></script>
    <!-- styles -->
    <style>
        .error-field {
            border: 1px solid #d96557;
        }

        .send-button {
            cursor: pointer;
            background: #3cb73c;
            border: #36a536 1px solid;
            color: #FFF;
            font-size: 1em;
            width: 100px;
        }
    </style>
</head>

<body>
    <!-- main container -->
    <div class="container">
        <!-- header -->
        <h3>PHP CSRF PROTECTION</h3>
        <!-- POST form -->
        <form action="" method="post" id="frm-contact" onsubmit="return validateContactForm()">
            <!-- row userName-->
            <div class="row">
                <!-- userName label -->
                <div class="label">
                    Name: <span id="userName-info" class="validation-message"></span>
                </div>
                <input type="text" name="userName" id="userName" class="frm-input" value="<?php if (!empty($_POST['userName'])&& $type == 'error') {
    echo $_POST['userName'];
}?>">
            </div>
            <!-- row userName ends here-->
            <!-- row email-->
            <div class="row">
                <!-- email label -->
                <div class="label">
                    Email: <span id="email" class="validation-message"></span>
                </div>
                <input type="email" name="email" id="email" class="frm-input" value="<?php if (!empty($_POST['email'])&& $type == 'error') {
    echo $_POST['email'];
}?>">
            </div>
            <!-- row email ends here-->
            <!-- row userName-->
            <div class="row">
                <!-- subject label -->
                <div class="label">
                    Subject: <span id="subject-info" class="validation-message"></span>
                </div>
                <input type="text" name="subject" id="subject" class="frm-input" value="<?php if (!empty($_POST['subject'])&& $type == 'error') {
    echo $_POST['userName'];
}?>">
            </div>
            <!-- row subject ends here-->
            <!-- row message starts here-->
            <div class="row">
                <div class="label">
                    Message: <span id="userMessage-info" class="validation-message"></span>
                </div>
                <textarea name="content" id="content" class="phppot-input" cols="60" rows="6"></textarea>
            </div>
            <!-- row message ends here-->
            <!-- submit/send info -->
            <div class="row">
                <input type="submit" name="send" class="send-button" value="Send" />
            </div>
        </form>

    <script src="assets/js/validate.js"></script>
</body>

</html>

表单页脚脚本触发服务处理程序来生成令牌。insertHiddenToken() 编写HTML代码,将csrf令牌字段加载到表单中。

  • view/framework/form-footer.php
<?php
require_once __DIR__ . '/../../lib/SecurityService.php';
$antiCSRF = new SecurityService\securityService();
$antiCSRF->insertHiddenToken();

第3步:在PHP中进行反跨站请求伪造(CSRF)验证

提交嵌入令牌的联系表单时,表单动作会执行以下脚本。SecurityService的validate() 函数将提交的令牌与存储在会话中的令牌进行比较。

如果发现匹配,它将进一步发送联系电子邮件。否则,它将向用户提供一个错误信息。

  • index.php (PHP CSRF验证和表单处理)
<?php
use MailService;

session_start();
if (!empty($_POST['send'])) {
    require_once __DIR__ . '/lib/SecurityService.php';
    $antiCSRF = new SecurityService\securityService();
    $csrfResponse = $antiCSRF->validate();
    if (!empty($csrfResponse)) {
        require_once __DIR__ . '/lib/MailService.php';
        $mailService = new MailService();
        $response = $mailService->sendContactMail($_POST);
        if (!empty($response)) {
            $message = "Hi, we have received your message. Thank you.";
            $type = "success";
        } else {
            $message = "Unable to send email.";
            $type = "error";
        }
    } else {
        $message = "Security alert: Unable to process your request.";
        $type = "error";
    }
}

?>

第4步:生成、插入、验证CSRF令牌的安全服务

这个在PHP中创建的服务类包括处理CSRF保护相关操作的方法。它定义了一个类属性来设置表单令牌字段名,会话索引。此外,它有方法生成令牌并将其写入HTML和PHP会话中。

此外,它使用XSS缓解,同时用令牌写入表单页脚。此外,它还可以从验证过程中排除一些URLs。

被排除的URL会绕过CSRF验证过程。相反,代码从PHP SERVER变量中获取当前请求的URL。

然后将其与排除的URL数组进行比较,跳过验证。

<? php
namespace SecurityService;
class securityService
{

    private $formTokenLabel = 'eg-csrf-token-label';

    private $sessionTokenLabel = 'EG_CSRF_TOKEN_SESS_IDX';

    private $post = [];

    private $session = [];

    private $server = [];

    private $excludeUrl = [];

    private $hashAlgo = 'sha256';

    private $hmac_ip = true;

    private $hmacData = 'ABCeNBHVe3kmAqvU2s7yyuJSF2gpxKLC';

    public function __construct($excludeUrl = null, &$post = null, &$session = null, &$server = null)
    {
        if (! \is_null($excludeUrl)) {
            $this->excludeUrl = $excludeUrl;
        }
        if (! \is_null($post)) {
            $this->post = & $post;
        } else {
            $this->post = & $_POST;
        }

        if (! \is_null($server)) {
            $this->server = & $server;
        } else {
            $this->server = & $_SERVER;
        }

        if (! \is_null($session)) {
            $this->session = & $session;
        } elseif (! \is_null($_SESSION) && isset($_SESSION)) {
            $this->session = & $_SESSION;
        } else {
            throw new \Error('No session available for persistence');
        }
    }

    public function insertHiddenToken()
    {
        $csrfToken = $this->getCSRFToken();

        echo "<!--\n--><input type=\"hidden\"" . " name=\"" . $this->xssafe($this->formTokenLabel) . "\"" . " value=\"" . $this->xssafe($csrfToken) . "\"" . " />";
    }


    public function xssafe($data, $encoding = 'UTF-8')
    {
        return htmlspecialchars($data, ENT_QUOTES | ENT_HTML401, $encoding);
    }


    public function getCSRFToken()
    {
        if (empty($this->session[$this->sessionTokenLabel])) {
            $this->session[$this->sessionTokenLabel] = bin2hex(openssl_random_pseudo_bytes(32));
        }

        if ($this->hmac_ip !== false) {
            $token = $this->hMacWithIp($this->session[$this->sessionTokenLabel]);
        } else {
            $token = $this->session[$this->sessionTokenLabel];
        }
        return $token;
    }

    private function hMacWithIp($token)
    {
        $hashHmac = \hash_hmac($this->hashAlgo, $this->hmacData, $token);
        return $hashHmac;
    }


    private function getCurrentRequestUrl()
    {
        $protocol = "http";
        if (isset($this->server['HTTPS'])) {
            $protocol = "https";
        }
        $currentUrl = $protocol . "://" . $this->server['HTTP_HOST'] . $this->server['REQUEST_URI'];
        return $currentUrl;
    }


    public function validate()
    {
        $currentUrl = $this->getCurrentRequestUrl();
        if (! in_array($currentUrl, $this->excludeUrl)) {
            if (! empty($this->post)) {
                $isAntiCSRF = $this->validateRequest();
                if (! $isAntiCSRF) {
                    // CSRF attack attempt
                    // CSRF attempt is detected. Need not reveal that information
                    // to the attacker, so just failing without info.
                    // Error code 1837 stands for CSRF attempt and this is for
                    // our identification purposes.
                    return false;
                }
                return true;
            }
        }
    }

    public function isValidRequest()
    {
        $isValid = false;
        $currentUrl = $this->getCurrentRequestUrl();
        if (! in_array($currentUrl, $this->excludeUrl)) {
            if (! empty($this->post)) {
                $isValid = $this->validateRequest();
            }
        }
        return $isValid;
    }

    public function validateRequest()
    {
        if (!isset($this->session[$this->sessionTokenLabel])) {
            // CSRF Token not found
            return false;
        }

        if (!empty($this->post[$this->formTokenLabel])) {
            // Let's pull the POST data
            $token = $this->post[$this->formTokenLabel];
        } else {
            return false;
        }

        if (is_string($token)) {
            return false;
        }

        // Grab the stored token
        if ($this->hmac_ip !== false) {
            $expected = $this->hMacWithIp($this->session[$this->sessionTokenLabel]);
        } else {
            $expected = $this->session[$this->sessionTokenLabel];
        }

        return \hash_equals($token, $expected);
    }

    /**
     * removes the token from the session
     */
    public function unsetToken()
    {
        if (! empty($this->session[$this->sessionTokenLabel])) {
            unset($this->session[$this->sessionTokenLabel]);
        }
    }
}

这个MailService.php 使用PHP核心mail() 函数来发送联系邮件。你可以用SMTP通过电子邮件发送脚本来代替它。检查这个以使用PHP获得一个IP地址。它可能对记录用户的IP地址很有用。

  • MailService.php
<?php
namespace csrfProtection;

class MailService
{

    function sendContactMail($postValues)
    {
        $name = $postValues["userName"];
        $email = $postValues["userEmail"];
        $subject = $postValues["subject"];
        $content = $postValues["content"];
        $toEmail = "ADMIN EMAIL";
        $mailHeaders = "From: " . $name . "(" . $email . ")\r\n";
        $response = mail($toEmail, $subject, $content, $mailHeaders);

        return $response;
    }
}

输出:来自服务器的CSRF验证响应

下面的图片显示了通常的联系表单。我们之前在很多联系表单教程中都看到过这个输出。在表单界面下面,图片显示了红色的安全警报信息。它确认了那些使用错误或空令牌发送请求的用户。

anti-csrf

结论

这样我们就在PHP联系表单中实现了反CSRF保护。我希望这个例子的代码是有用的,你能得到我们在这里讨论的实施过程。

我们已经在PHP中创建了一个SecurityService类来处理CSRF保护。在你需要启用CSRF保护的地方,它可以在多个应用程序中重复使用。返回响应信息的PHP代码正确地确认了用户。