我博客上有两个页面是不对外公开的:一个放私人日记,一个放些自己的小工具。它们不在导航里,但知道地址的人就能访问——所以需要一道密码门。

这篇文章讲我怎么用最少的代码实现它,以及这个方案的安全边界在哪里。最后一部分可能比实现本身更重要。

需求很简单

我想要的只有三件事:

一、没输密码的人看不到内容。不是"藏起来",是**服务器根本不把内容发出去**。

二、输一次密码,接下来几分钟内不用反复输。

三、不用数据库,不用用户系统,不用记住"谁登录过"。

第三点是关键。我不想为了两个页面去搭一套完整的账号体系——那意味着用户表、会话表、密码找回流程、还有一堆可能出漏洞的地方。

核心思路

既然只有一个密码、只区分"知道"和"不知道"两种状态,那就不需要服务端记录任何东西:

把密码的 SHA-256 哈希硬编码在模板里。用户提交密码时,服务端算一次哈希,和硬编码的值比较——相等就发一个 Cookie,Cookie 的值就是那个哈希。之后每次请求,服务端只需比较 Cookie 和硬编码值是否相等。

整个过程服务端不存任何状态。密码本身不会被存储,存储的是它的哈希。

define('MY_TOOLS_HASH', '5b47341713671815eab5b2e6fd6fe7879b83224001c678a7a3f0994b6e328fe3');

$isAuthenticated = false;
$error = false;

if (isset($_COOKIE['my_tools_auth'])) {
    $isAuthenticated = hash_equals(MY_TOOLS_HASH, $_COOKIE['my_tools_auth']);
}

if (!$isAuthenticated && isset($_POST['tools_password'])) {
    $input = hash('sha256', $_POST['tools_password']);
    if (hash_equals(MY_TOOLS_HASH, $input)) {
        setcookie('my_tools_auth', $input, time() + 300, '/');
        header('Location: ' . $redirectUrl);
        exit;
    } else {
        $error = true;
    }
}

就这么十几行。但里面有三处细节值得展开说。

细节一:为什么用 hash_equals 而不是 ==

这是整段代码里最容易被忽略、也最值得学的一点。

PHP 的 == 比较字符串时,发现第一个不同的字符就立刻返回 false。这意味着比较耗时和"前面对上了多少个字符"成正比。

攻击者可以借此反推正确值:如果输入的第一位就错,比较很快返回;如果前三位都对,第四位才错,耗时会稍长。通过成千上万次请求测量这个时间差,理论上能逐位猜出正确值。这叫时序攻击(timing attack)。

hash_equals() 是专门为此设计的:无论哪个位置不同,它都会把两个字符串完整比较一遍,耗时恒定。

虽然说句实话——在公网环境下,网络抖动带来的噪声远大于这个时间差,时序攻击在真实场景里极难奏效。但用正确的函数是零成本的,没有任何理由不用。

细节二:POST 之后要重定向

注意密码验证成功后的这三行:

setcookie('my_tools_auth', $input, time() + 300, '/');
header('Location: ' . $redirectUrl);
exit;

验证成功不直接渲染页面,而是发一个 302 跳转回当前地址。

这是 PRG 模式(Post/Redirect/Get)。如果不跳转,用户在这个页面上按 F5 刷新,浏览器会重新提交一次表单——密码被再发一遍。虽然这里重复提交没有副作用,但更麻烦的是浏览器会弹出"是否重新提交表单"的提示框,体验很差。

重定向之后,地址栏里是一个 GET 请求,刷新就只是重新加载页面。

细节三:Cookie 每次访问都续期

// 已登录状态下,每次请求都重新设置一次过期时间
setcookie('my_tools_auth', $_COOKIE['my_tools_auth'], time() + 300, '/');

300 秒是 5 分钟。关键在于这个时间会在每次访问时重新计算

所以它其实是一个空闲超时,不是绝对超时:你连续翻页的话,登录状态会一直保持;停手超过 5 分钟,下次就要重新输密码。

这个设计刚好符合我的使用场景——自己看日记时不会看一半被踢出去,而在别人的电脑上打开,离开几分钟后就自动锁上了。

让内容根本不发出去

前面说的是"怎么判断有没有权限"。但还有一个同样重要的问题:没权限的人,页面内容会不会被发到他的浏览器里?

很多人的做法是用 CSS 把内容 display:none 藏起来,或者用 JS 控制显示。这是错的——打开开发者工具就能看到全部内容,甚至直接查看网页源代码就行。

正确的做法是在服务器端就分成两条互斥的分支:

<?php if ($isAuthenticated): ?>

    <!-- 已认证:输出日记正文 -->

<?php else: ?>

    <div class="password-gate">
        <div class="password-gate-icon">&#128274;</div>
        <h2>恋爱日记</h2>
        <p>这篇文章需要密码才能查看</p>
        <form method="post" class="password-form">
            <input type="password" name="love_password" placeholder="请输入密码" required autofocus>
            <button type="submit">确认</button>
        </form>
        <?php if ($error): ?>
        <p class="password-error">密码错误,请重试</p>
        <?php endif; ?>
    </div>

<?php endif; ?>

未认证时,PHP 走的是 else 分支,正文那部分代码根本不会执行,HTML 里也不会有任何日记内容。查看源代码只能看到一个密码表单。

这是一条通用原则:任何你应该看不到的东西,都不应该被发送到你的浏览器上。

四个问题,以及各自的解法

第一版跑通之后,我拿它给懂安全的朋友看了一遍,被指出了四个问题。这一节把每个问题、为什么要紧、以及该怎么改都写清楚。

问题一:Cookie 的值等于密码凭证

问题在哪。Cookie 里存的是密码的 SHA-256 哈希,而服务端比较的正是"Cookie 值 == 硬编码哈希"。所以拿到这个 Cookie 的人不需要知道原始密码,把值复制过去就能通过验证。

更麻烦的是它永不过期。虽然浏览器端设了 300 秒,但那只是浏览器自觉——攻击者手动构造一个同名 Cookie,服务端照样认。

我之前一直以为"Cookie 5 分钟就失效了",其实失效的只是浏览器手里的那份副本。

怎么改。把 Cookie 从"密码凭证本身"换成带签名的临时令牌

// 签名密钥,随机生成一个长字符串写死在模板里
// 可以用 bin2hex(random_bytes(32)) 生成
define('GATE_SECRET', '换成你自己的随机密钥');

// ---------- 签发 token ----------
function issueToken($ttl = 300) {
    $expires = time() + $ttl;
    $sig = hash_hmac('sha256', (string)$expires, GATE_SECRET);
    return $expires . '.' . $sig;
}

// ---------- 校验 token ----------
function verifyToken($token) {
    $parts = explode('.', $token);
    if (count($parts) !== 2) return false;

    [$expires, $sig] = $parts;
    if (!ctype_digit($expires)) return false;

    // 先看有没有过期
    if ((int)$expires < time()) return false;

    // 再用密钥重算一遍签名,比对是否一致
    $expected = hash_hmac('sha256', $expires, GATE_SECRET);
    return hash_equals($expected, $sig);
}

改动的核心在于:Cookie 里不再放密码,而是一段"到期时间 + 它的签名"。

签名是用只有服务端知道的密钥算出来的。攻击者如果想伪造一个"2030 年才过期"的 token,就得先知道密钥——而密钥不在 Cookie 里,拿不到。

而且这个方案依然不需要数据库:到期时间写在 token 里,签名用来验证它没被篡改,服务端不存任何状态。我最初"不要用户系统"的目标还在。

验证通过后把 token 回写给浏览器:

if (verifyToken($_COOKIE['my_tools_auth'] ?? '')) {
    $isAuthenticated = true;
    // 滑动续期:换发一张新票据
    setcookie('my_tools_auth', issueToken(), [
        'expires'  => time() + 300,
        'path'     => '/',
        'secure'   => true,
        'httponly' => true,
        'samesite' => 'Strict',
    ]);
}

问题二:Cookie 缺少三个安全属性

问题在哪。原来只传了 path:

setcookie('my_tools_auth', $input, time() + 300, '/');

缺少的三个属性各自防一类攻击:

属性防止什么
HttpOnlyJavaScript 读不到这个 Cookie。万一站点某处有 XSS 漏洞,注入的脚本也偷不走它
Secure只在 HTTPS 连接上发送,避免被降级到 HTTP 时明文暴露
SameSite跨站请求时不携带。防止别的网站诱导用户浏览器发出带 Cookie 的请求(CSRF)

怎么改。PHP 7.3 之后支持用数组传参,比记那串位置参数清楚得多:

setcookie('my_tools_auth', issueToken(), [
    'expires'  => time() + 300,
    'path'     => '/',
    'secure'   => true,      // 只在 HTTPS 下发送
    'httponly' => true,      // 禁止 JS 读取
    'samesite' => 'Strict',  // 禁止跨站携带
]);

SameSite 有两个值可选:Lax(默认,允许从别的站点点击链接时带上)和 Strict(一律不带)。

密码门应该用 Strict——因为这个页面不需要从外部链接直接跳进来。如果用了 Lax,别人在论坛发一个指向你私密页面的链接,用户点进来时 Cookie 会被带上,直接就进去了。

问题三:密码可以无限次尝试

问题在哪。输错密码没有任何代价——不延迟、不锁定、不出验证码。写个脚本就能一直试。

我当初的想法是"密码够长就行了"。但密码再长也架不住一个常识:人会复用密码,也会用有规律的密码。我的密码就是「名字缩写 + 生日 + 手机号」的组合,这种规律在社工库面前基本等于裸奔。

怎么改。加一个基于文件的失败计数器。没有数据库,就用文件存:

define('GATE_LOCK_FILE', __DIR__ . '/../usr/uploads/.gate_lock');
define('MAX_ATTEMPTS', 5);
define('LOCK_SECONDS', 900);   // 锁定 15 分钟

function isLocked() {
    if (!file_exists(GATE_LOCK_FILE)) return false;

    $data = json_decode(file_get_contents(GATE_LOCK_FILE), true);
    if (!$data) return false;

    if ($data['count'] < MAX_ATTEMPTS) return false;

    // 锁定期过了就重置
    if (time() - $data['last'] > LOCK_SECONDS) {
        unlink(GATE_LOCK_FILE);
        return false;
    }
    return true;
}

function recordFailure() {
    $data = ['count' => 0, 'last' => time()];
    if (file_exists(GATE_LOCK_FILE)) {
        $old = json_decode(file_get_contents(GATE_LOCK_FILE), true);
        if ($old && time() - $old['last'] < LOCK_SECONDS) {
            $data['count'] = $old['count'];
        }
    }
    $data['count']++;
    file_put_contents(GATE_LOCK_FILE, json_encode($data));
}

在验证逻辑里接上:

if (!$isAuthenticated && isset($_POST['tools_password'])) {
    if (isLocked()) {
        $error = '尝试次数过多,请 15 分钟后再试';
    } else {
        $input = hash('sha256', $_POST['tools_password']);
        if (hash_equals(MY_TOOLS_HASH, $input)) {
            @unlink(GATE_LOCK_FILE);   // 成功就清空计数
            setcookie('my_tools_auth', issueToken(), [...]);
            header('Location: ' . $redirectUrl);
            exit;
        } else {
            recordFailure();
            $error = '密码错误,请重试';
        }
    }
}

几个细节:

计数器文件放在 usr/uploads/ 下,因为那个目录本来就有写权限。文件名以 . 开头,避免被直接访问到。

成功登录时清空计数——否则你自己试错几次之后,也会被锁在门外。

这个方案是按站点全局计数的,不是按 IP。也就是说别人攻击你,你自己也会被误锁。对于单人博客这是可以接受的取舍;如果要做多用户系统,计数维度得换成"IP + 用户名"。

问题四:SHA-256 不是密码哈希算法

问题在哪。我用 hash('sha256', ...) 存密码。但 SHA-256 是为速度设计的——现代显卡每秒能算几十亿次。

这意味着两件事:一是没加盐,同样的密码永远得到同样的哈希,可以用彩虹表反查;二是太快,暴力破解的成本极低。

哈希是硬编码在模板里的,服务器被入侵的话对方本来就能读到源码——所以在这个具体场景下,哈希泄露的危害有限。但这是"场景特殊",不是"做法正确"。

怎么改。用 PHP 内置的 password_hash()

// ---------- 生成哈希(执行一次,把结果填进模板)----------
$hash = password_hash('你的密码', PASSWORD_DEFAULT);
// 输出类似:$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

// ---------- 验证 ----------
if (password_verify($_POST['tools_password'], GATE_HASH)) {
    // 通过
}

它一次性解决两个问题:

自动加盐。每次生成的结果都不一样,彩虹表彻底失效。盐值直接编码在哈希字符串里($2y$10$ 后面的部分),不需要额外存储。

可调计算强度。PASSWORD_DEFAULT 目前用的是 bcrypt,$10$ 表示迭代 210 次。相比 SHA-256 的一次运算,暴力破解的成本提高了好几个数量级。

而且它自带向前兼容:以后 PHP 升级换了更强的默认算法,同一个 PASSWORD_DEFAULT 会自动用上新算法。

需要注意的是哈希长度从 64 位变成 60 位左右,但仍然是固定长度,直接替换常量即可:

// 旧
define('MY_TOOLS_HASH', '5b47341713671815eab5b2e6fd6fe7879b83224001c678a7a3f0994b6e328fe3');

// 新
define('GATE_HASH', '$2y$10$你的password_hash输出');

改完之后

四条改完,验证逻辑从十几行变成四十多行。逐条对照:

问题改法效果
Cookie 可重放HMAC 签名 token复制的凭证会真正过期,且无法伪造
Cookie 属性缺失数组传参补全三个属性防 XSS 窃取、防降级、防 CSRF
无失败限流文件计数器 + 15 分钟锁定暴力破解不可行
SHA-256 存密码password_hash()自动加盐 + 计算强度可调

有意思的是,改完之后代码反而更简单了——原来的实现里,我得自己解释"为什么用 hash_equals"、"为什么 Cookie 里放哈希";换掉之后这些都是标准做法,不需要额外说明了。

小结

第一版不到 100 行,实现了密码门控、5 分钟空闲超时、服务端不存储状态、内容不下发——功能上完全能用。

但被人指出那四个问题之后我才意识到:能用和经得起推敲是两回事。

这次最有价值的收获,不是学会了写密码门,而是学会了在写完之后问自己一句"这段代码如果被别人拿去做安全审查,会从哪里被攻破"。

上面这四条我第一版的时候一条都没想到。写出来提醒自己,也提醒看到这里的你:自己写的代码,自己很难看出问题——要么给别人看,要么换一个"攻击者"的视角重新读一遍。

恋爱日记密码门
恋爱日记的密码门。未认证时服务器只输出这个表单
我的工具密码门
同样的机制用在了"我的工具"页面上