给博客加一道密码门:SHA256 + Cookie 鉴权
我博客上有两个页面是不对外公开的:一个放私人日记,一个放些自己的小工具。它们不在导航里,但知道地址的人就能访问——所以需要一道密码门。
这篇文章讲我怎么用最少的代码实现它,以及这个方案的安全边界在哪里。最后一部分可能比实现本身更重要。
需求很简单
我想要的只有三件事:
一、没输密码的人看不到内容。不是"藏起来",是**服务器根本不把内容发出去**。
二、输一次密码,接下来几分钟内不用反复输。
三、不用数据库,不用用户系统,不用记住"谁登录过"。
第三点是关键。我不想为了两个页面去搭一套完整的账号体系——那意味着用户表、会话表、密码找回流程、还有一堆可能出漏洞的地方。
核心思路
既然只有一个密码、只区分"知道"和"不知道"两种状态,那就不需要服务端记录任何东西:
把密码的 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">🔒</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, '/');缺少的三个属性各自防一类攻击:
| 属性 | 防止什么 |
|---|---|
HttpOnly | JavaScript 读不到这个 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 分钟空闲超时、服务端不存储状态、内容不下发——功能上完全能用。
但被人指出那四个问题之后我才意识到:能用和经得起推敲是两回事。
这次最有价值的收获,不是学会了写密码门,而是学会了在写完之后问自己一句"这段代码如果被别人拿去做安全审查,会从哪里被攻破"。
上面这四条我第一版的时候一条都没想到。写出来提醒自己,也提醒看到这里的你:自己写的代码,自己很难看出问题——要么给别人看,要么换一个"攻击者"的视角重新读一遍。
暂无评论