使用 PHP 防止页面被直接访问
最近我一直在琢磨,怎样用 PHP 来限制对页面的访问。
创建一个用户认证系统,这是显而易见的办法,但在某些情况下,对于所需的功能而言,这么做有些大材小用。要是你只是想防止用户直接访问某个页面,倒有几种方法可以选择。
在本文中,我会探讨如何在不借助用户认证系统的情况下,防止页面被直接访问,还会分析每种方法的优缺点。要是你正在找保护页面的方法,说不定这些方法里有适合你的。在 Drupal 开发或 Drupal 模块开发过程中,这种防止页面被直接访问的技术也很有用,特别是在 Drupal11 版本中,对页面访问的控制和安全性要求更高。
在下面所有示例里,我们假定存在一个“源页面”,用户能完全访问;还有一个“受保护页面”,得先访问源页面,才能访问该受保护页面。为简化示例,我们只是停止页面的执行,而不是向用户显示漂亮的错误页面。
本文中所有代码都能在与本文配套的示例代码仓库中查看,该仓库展示了所有这些示例的运行情况,还包含了更完善的访问拒绝消息。
一、引用页
最简单的办法是,等用户点击指向我们受保护页面的链接后,查看服务器详情中的引用页值。
用户点击某页面上的链接,会被带到下一个页面,这是正常情况。而且在该请求中会包含一个名为“Referer”的头部信息,它会记录用户点击链接前所在的页面。需要说明的是,这里拼写有误,但它被写进了早期的 HTTP/1.0 协议 RFC(RFC1945)中,并且从未更改过。
在 PHP 中,可以通过两种方式访问该变量。一是使用 getallheaders() 函数,该函数会包含我们要查找的头部信息。
$referrer = getallheaders()['Referer'];
或者,通过 $_SERVER 超全局变量,PHP 用这个变量存储相同的头部信息,方便我们访问。
$referrer = $_SERVER['HTTP_REFERER'];
为了保护相关页面,我们得确定希望检测用户来自哪个页面,然后确保该页面与 Referer 头部信息中的值匹配。
// 用户必须首先访问的页面位置。
$allowed_referrer = '/referrer/';
if (!isset($_SERVER['HTTP_REFERER']) || !str_contains($_SERVER['HTTP_REFERER'], $allowed_referrer)) {
// 用户不是从正确的页面访问的,因此拒绝访问。
header('HTTP/1.0 401 Unauthorized');
die('Invalid referrer.');
}
虽说这是最简单的方法,但也是最容易被绕过的。把“Referer”头部信息注入请求,以绕过我们设置的保护机制非常简单。
优点
- 易于实现。
缺点
- 在实现引用页检查时得谨慎,要确保考虑到用户可能点击指向受保护页面链接的所有位置。
- 一些病毒防护系统和防火墙可能会删除引用页头部信息,从而破坏此功能。
- 引用页头部信息很容易被伪造。
由于引用页头部信息很容易被绕过且经常缺失,所以最好不要完全依赖它。
二、Cookie
通过在第一个页面设置一个 cookie,我们就能确保在第二个页面检查该 cookie 是否存在,若不存在,就发出访问拒绝消息,从而保护第二个页面。在 Drupal 开发里,正确运用 cookie 来保护页面资源也是 Drupal 升级和 Drupal11 安全性提升的一部分。
为了实现这一点,我们在第一个页面使用 PHP 的 setcookie() 函数设置一个 cookie。在下面的示例中,我们在 cookie 内容中设置了“allow”这个词,后续我们会检查这个值。我们还将 cookie 的过期时间设置为 1 小时,以避免用户一直可以无限制地访问该页面。
// 为网站的这部分设置一个 1 小时的 cookie。
setcookie("cookie_protected", 'allow', time()+3600, '/cookie/');
在我们想要保护的页面上,只需检测该 cookie 是否存在即可。
如果 cookie 存在,我们要确保其内容与我们预期的一致,然后允许访问该页面。一旦授予访问权限,我们将 cookie 的过期时间设置为过去,从而删除该 cookie。
if (!isset($_COOKIE['cookie_protected']) || $_COOKIE['cookie_protected'] !== 'allow') {
// Cookie 未设置或不包含我们期望的值。
header('HTTP/1.0 401 Unauthorized');
die('Invalid cookie.');
}
// 删除 cookie。
setcookie("cookie_protected", '', time()-3600, '/cookie/');
删除 cookie 可以确保用户只能访问该页面一次。
我们可以通过在 cookie 中设置更安全的值来提高此机制的安全性。本文后面会介绍一些设置安全值的示例(见 CSRF 或 JWT)。
优点
- 易于实现。
- 通过在使用访问密钥(即 cookie)后将其删除,可以允许用户仅查看页面一次。
缺点
- cookie 可以通过多种方式被阻止,这会导致此系统无法正常工作。
- 在未经用户同意的情况下,不应该向用户发送 cookie,因此在使用此系统之前,需要设置一个 cookie 同意表单。
三、动态链接
另一种保护页面的方法是创建一个动态链接,该链接会根据用户访问页面时携带的属性发生变化。在 Drupal 模块开发中,动态链接的应用可以有效增强 Drupal 系统的页面访问控制,特别是在 Drupal11 中对页面的动态安全管理需求。
例如,我们可以获取用户的 IP 地址和当前时间,并将它们编码到链接变量中。
$link = base64_encode($_SERVER['REMOTE_ADDR'] . '/' . time());
然后,我们将该链接变量作为属性附加到我们想要保护的页面上。
<a href="/dynamic-link/protected.php?link=<?php echo $link ?>">Dynamic Link Protected</a>
生成的链接看起来如下所示:
/dynamic-link/protected.php?link=MTcyLjE4LjAuNS8xNzQ1NjYxNjE4
受保护页面需要对我们在链接中传递的信息进行解码,并将其与用户的 IP 地址和当前时间进行验证。如果当前用户不符合给定的条件,我们将拒绝其访问该页面。
// 假设用户未授权。
$authorised = false;
if (isset($_GET['link'])) {
// 我们找到了链接参数,提取数据并进行验证。
$link = base64_decode($_GET['link']);
list($ip, $time) = explode('/', $link);
if ($ip === $_SERVER['REMOTE_ADDR'] || $time >= (time() - 60)) {
// 验证通过,允许访问。
$authorised = true;
}
}
if ($authorised === false) {
// 未授予授权,因此发出访问拒绝消息。
header('HTTP/1.0 401 Unauthorized');
die('Invalid link.');
}
使用 IP 地址意味着我们无需事先存储任何 cookie 或其他关于用户的信息,这些信息存储在链接参数中。包含时间不仅意味着每次访问时 base64 编码的字符串都是唯一的,而且链接还具有内置的时间限制,我们可以将其纳入保护机制中。
优点
- 我们生成的链接有一定的有效期,无需实现任何会话管理系统。
- 此方法相当可靠,因为它不依赖于任何会话或 cookie 设置。
缺点
- 此机制使用简单的 base64 编码/解码方法,如果攻击者花时间研究,很容易绕过。应该使用更安全的加密或令牌加密和验证系统。
四、表单
用户可以通过点击链接从一个页面跳转到另一个页面,但通过提交表单,我们也能实现相同的操作,并且可以从一个页面向另一个页面传递更多数据。在 Drupal 开发中,表单的合理运用可以有效控制页面的访问流程,有利于 Drupal11 站点的安全防护。
以下是一个简单的 HTML 表单,其中包含一个隐藏字段,该字段将构成我们的保护数据。当用户提交表单时,会向受保护页面发送一个 POST 请求。
<form method="post" action="protected.php">
<input type="hidden" name="protected_form" value="true" />
<input type="submit" value="View Protected Page" />
</form>
这里我们需要使用“POST”方法,因为使用“GET”方法会将属性附加到 URL 上,用户可以将其分享给任何人。
在表单提交的另一方,我们只需要检查表单是否被提交,以及隐藏字段的值是否与我们期望的值匹配。
if (!isset($_POST['protected_form']) || $_POST['protected_form'] === true) {
// 用户未提交表单,拒绝访问。
header('HTTP/1.0 401 Unauthorized');
die('Invalid form submission.');
}
为了进一步增强保护,我们可以使受保护表单的值更具动态性。在当前状态下,我们的表单很容易被绕过,因为我们只需要向 POST 请求中注入未哈希或未加密的值即可。
有关如何创建安全的表单令牌的详细信息,请参阅本文后面的 CSRF 部分。
优点
- 易于实现。只需一个表单和一个提交处理程序。
- 用户提交表单并进入受保护页面后,若要再次查看该页面,需要重新提交表单。
缺点
- 如果用户刷新受保护页面,浏览器会询问是否重新提交之前的值。这并不是很好的用户体验,但确实增加了一定程度的保护。
五、IP 地址
这可能看起来是个简单的示例,但 IP 地址限制相当常见,所以我想在这里把它作为一个额外的示例介绍一下。在 Drupal 开发和 Drupal 升级过程中,IP 地址的限制也可以作为一种辅助的页面安全防护手段,特别是在 Drupal11 中对特定页面的访问控制。
要使此保护机制生效,我们只需要获取用户的 IP 地址,然后检查该 IP 地址是否符合我们的预期。如果不符合,我们就拒绝用户访问该页面。
// 获取用户 IP 地址。
$ip = $_SERVER['REMOTE_ADDR'];
if ($ip !== '172.18.0.5') {
// IP 地址不正确,发出访问拒绝消息。
header('HTTP/1.0 401 Unauthorized');
die('Invalid IP address');
}
当然,要使此方法生效,你需要事先知道用户的 IP 地址。需要事先收集一些 IP 地址并配置保护页面。
优点
- 易于实现,你只需要知道用户的 IP 地址。
缺点
- 你需要事先知道要限制的正确 IP 地址(或地址范围),这意味着该方法对于公共网站来说用处不大。
- IP 地址欺骗并非易事,但并非不可能。
- IP 地址通常会被很多人共享,或者每次请求时可能会发生变化。
- 在代理服务器、负载均衡器和 VPN 的环境中,要真正获取用户的 IP 地址并非易事。你需要确保应用程序的每个访问层都将客户端 IP 地址转发到你的网站。
六、CSRF 令牌
CSRF 代表“跨站请求伪造”,当页面没有正确检查用户是否来自正确的位置时,就会出现这种类型的漏洞。这看起来可能是个小问题,但可能会导致严重的问题,特别是当目标页面期望接收表单提交时。
例如,假设你访问了一个恶意网站,该网站会在后台悄悄发送一个 POST 请求到你的某个社交媒体网站。由于你已经登录到社交媒体网站,该 POST 请求会在你不知情的情况下代表你发布内容,或者更糟糕的是,更改你的密码并将账户所有权转移给攻击者。如果社交媒体网站没有检查请求的来源,就会无疑问地接受该请求,从而导致攻击发生。
这不一定是社交媒体网站,也可能是银行或信用卡网站遭受攻击,如果该网站没有防止 CSRF 问题,请求将被接受,你可能会因此损失钱财。
CSRF 令牌是一种可以防止此类攻击的机制。我们会注册一个令牌,然后检查传入的请求(无论它是如何生成的)是否在页面请求中包含相同的令牌。如果不包含,我们就可以认为用户不是来自正确的位置,并阻止该请求。
为了保护页面,CSRF 令牌非常合适,因为它的设计目的是确保用户来自生成 CSRF 令牌的页面。我们只需要使用一些随机字符生成一个随机令牌,并使用安全的哈希值对其进行哈希处理。然后将该令牌存储在会话中,以便后续检查。
// 启动会话,以便我们可以在其中存储 CSRF 令牌。
session_start();
// 生成 CSRF 令牌。
$csrf = hash('sha256', bin2hex(random_bytes(15)));
// 将 CSRF 令牌存储在会话中。
$_SESSION['csrf'] = $csrf;
CSRF 令牌可以嵌入到表单的隐藏字段中,但为了实现这个目的,我们只需创建一个 URL 参数,并将其嵌入到指向受保护页面的链接中。
<a href="/csrf/protected.php?csrf=<?php echo $_SESSION['csrf']; ?>">CSRF Protected</a>
当我们加载受保护页面时,我们启动会话以从会话数据中获取生成的 CSRF 令牌。然后将这个值与通过参数传递到页面的 CSRF 令牌进行比较。如果它们不匹配,我们就拒绝访问。
// 启动会话,以便我们可以从中获取 CSRF 令牌。
session_start();
// 获取传递的 CSRF 令牌和存储在会话中的令牌。
$passedCsrf = $_GET['csrf'] ?? FALSE;
$csrf = $_SESSION['csrf'] ?? FALSE;
if ($passedCsrf === FALSE || $csrf === FALSE || $passedCsrf !== $csrf) {
// 令牌不匹配,拒绝用户访问。
header('HTTP/1.0 401 Unauthorized');
die('Invalid CSRF');
}
由于每次页面请求时 CSRF 令牌都是随机生成的,用户无法猜测该令牌,因此页面仍然受到保护。
优点
- 这是一种相当安全的机制,即使使用像 MD5 这样不安全的哈希算法,仍然需要令牌和会话才能使其正常工作。
- 生成的令牌不能传递给其他用户,但如果用户需要,页面可以多次加载。
缺点
- 此机制需要会话才能正常工作。如果用户阻止了 cookie,那么该机制将无法正常工作。
七、JWT
JSON Web 令牌(JWT)是一个开放标准(参见 RFC 7519),它允许双方相互验证消息。它通常用于验证两个不同系统之间的用户身份,甚至进行身份验证,在 API 和移动应用中广泛使用。在 Drupal 开发尤其是 Drupal 模块开发和 Drupal11 升级中,JWT 可用于安全的用户身份验证与页面访问控制。
我可以写一篇(相当长的)文章来介绍 JWT 的工作原理以及在生成 JWT 时可以使用的哈希机制。简单来说,我们会生成一个密钥,用于创建有效负载的哈希值,然后在请求中使用该哈希值。我们可以解构这个有效负载,并使用原始密钥验证其内容。这确保我们接收到的 JWT 有效负载与生成的 JWT 有效负载完全匹配。
JWT 并非加密安全的,但它能确保双方之间传递的任何数据都可以被验证为正确。
要开始使用 JWT,我们需要定义一个密钥,将其设置为 PHP 常量。
const JWT_KEY = '9r87wde09r80w9ercqw98rcu436o5j4klhkrehjtrethutq2qurwiru';
注意:确保此密钥的安全性。永远不要将此密钥提交到代码仓库或放在不安全的位置,否则你将无法验证 JWT 是否来自你的服务。
然后使用此密钥对请求的有效负载进行哈希处理,该有效负载本质上是一个包含用户某些信息的数组。
这里我不会贴出 JWT 令牌编码和解码的代码,我会提供本文示例 GitHub 仓库的链接,其中包含了在 PHP 中加密和解密 JWT 数据所需的代码。从这个示例代码中,我们有一个名为 jwt_encode() 的函数,用于将我们的有效负载编码为 JWT,还有一个名为 jwt_decode() 的函数,用于将 JWT 解码为有效负载并验证有效负载的签名是否正确。
在第一个页面上,我们创建一个包含用户 IP 地址和当前时间戳的有效负载,然后将其编码为 JWT。
// 生成我们的有效负载。
$payload = [
"ip" => $_SERVER["REMOTE_ADDR"],
"time" => time(),
];
// 对我们的有效负载进行编码。
$jwt = jwt_encode($payload, JWT_KEY);
由于有效负载只是一个字符串,我们可以将其添加到受保护的链接中。
<a href="/jwt/protected.php?jwt=<?php echo $jwt; ?>">JWT Protected</a>
在受保护页面上,我们只需获取令牌,对其进行解码,然后验证解码后的值是否与我们当前拥有的用户信息匹配。如果令牌的签名不正确,JWT 解码可能会失败,在这种情况下,我们无法验证令牌的真实性,并拒绝用户访问。
// 从请求中获取令牌。
$jwt = $_GET["jwt"] ?? '';
// 尝试解密
try {
$jwt = jwt_decode($jwt, JWT_KEY);
} catch (Exception $e) {
header('HTTP/1.0 401 Unauthorized');
die('Invalid JWT');
}
if (!isset($jwt['ip']) || !isset($jwt['time']) && ($jwt['ip'] !== $_SERVER["REMOTE_ADDR"] && $jwt['time'] <= (time() - 60))) {
header('HTTP/1.0 401 Unauthorized');
die('Invalid JWT');
}
在上述示例中,我们使用了用户的 IP 地址和当前时间。使用基于时间的 JWT 是个好主意,因为这意味着它们的使用时间不会太长,所以如果你的密钥被泄露,任何现有的 JWT 令牌都会很快过期并变得无用。
优点
- 虽然这里的有效负载不安全,但我们可以确保接收到的 JWT 是使用相同的密钥创建的,并且包含经过验证的信息。
缺点
- 这不是加密,因此无法处理任何安全数据。有针对性的攻击者可以解密此有效负载并检查其内容,但他们无法更改它,因为这会使 JWT 签名无效。
- 实现起来有些复杂。要使这一切正常工作需要相当多的复杂代码,调试 JWT 问题也有些复杂。
八、基本认证
我意识到从技术上讲这是一种认证方式,但我还是决定把它包含进来,因为它非常容易实现。而且在链接到页面时,将凭证注入 URL 也很简单。
在这个页面上,我们只需要设置受保护页面以从请求中提取基本认证信息,并确保它们匹配。PHP 通过将这些字段包装在 $_SERVER 超全局变量中,免费为我们提供了这些字段。
// 获取用户凭证。
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$pass = $_SERVER['PHP_AUTH_PW'] ?? '';
// 验证凭证。
$validated = ($user === 'user') && ($pass === 'pass');
if (!$validated) {
// 发出认证头部信息。
header('WWW-Authenticate: Basic realm="Protected Page"');
header('HTTP/1.0 401 Unauthorized');
die('Unauthorised');
}
为了让用户感觉不到认证过程,你只需将认证信息注入到链接中即可。
<a href="//user:pass@example.com/basicauth/protected.php">Basic Auth Protected</a>
当用户点击此链接时,它会将会话凭证传输到受基本认证保护的页面并让用户登录。
优点:
- 只需要一个头部信息和验证检查。
缺点
- 请求的用户凭证对用户来说是完全可见的,在这种情况下,用户很容易重新构造这些凭证并创建自己的链接。
- 你无法让用户注销。一旦用户登录,就很难阻止其访问。
九、结论
除了要求用户进行认证之外,还有多种不同的机制可用来保护页面。通常,最安全的机制是创建一个令牌,当用户访问受保护页面时可以对其进行验证。
仅使用用户的 IP 地址通常不足以保护页面,因为它很容易被伪造,但如果你能够为用户创建一个会话变量,就可以使用它来确保用户身份的匹配。PHP 请求的无状态性质意味着你需要在用户从未受保护页面跳转到受保护页面后,验证用户的身份。
要是你想为自己的项目保护一个页面,我建议首先考虑 CSRF 令牌,如果无法使用会话,可能可以考虑使用 JWT。由于这两种技术都已经经过验证,因此针对每种技术都有大量的文档可供参考。我还建议将 CSRF 令牌嵌入到表单中,而不是作为 URL 参数,这样可以更好地保护受保护页面,防止用户重复访问。
尽管这是一个很长的示例列表,但我在想是否遗漏了任何示例。如果你能想到任何不依赖用户认证的页面保护机制,请在评论中告诉我。
我还想指出,虽然我已尽力使这些示例尽可能全面,但在生产环境中使用之前,应该对结果进行彻底测试。此外,如果你要通过互联网发送用户信息,应该始终使用 HTTPS 以确保请求的安全性。
本文中所有代码都可以在与本文配套的示例代码仓库中查看,该仓库还包含了如何启动和运行该项目的说明。


