自建站密码存储与登录安全:从bcrypt到动态口令

注册登录模块为什么总是安全短板

自建站和成品 CMS 最大的区别之一,是注册登录这块代码往往要自己写。框架提供了会话管理和密码函数,但从「用户提交的密码怎么存」到「登录后怎么维持身份」中间的每一步判断,都得站长自己负责。这块代码一旦写松,后果不是丢一个页面,而是整个后台连同数据库凭证一起被拿走。

这篇文章讲三件事:密码到底该怎么存、密码之外的第二道门怎么做、以及登录流程里那些「看起来无害其实致命」的判断。内容按一个自建 PHP 站的实际情况来写,不需要任何第三方登录服务,全部可以在自己服务器上落地。

第一层:密码存储的三个时代

密码存储的演进可以粗暴地分成三代,理解这三代的差别,比记住某个函数名重要得多。

第一代是明文和单次哈希。明文不用多说。单次哈希的典型写法是 md5($password) 或 sha1($password),它的问题有两层:一是这两个算法本来就是为「快」设计的,现代显卡每秒可以试几十亿次;二是没有加盐时,攻击者可以用彩虹表批量反查,同一个密码在所有用户身上都是同一个哈希值。

第二代是加盐哈希。给每个用户生成随机盐,把盐和密码拼起来再哈希。password_hash() 就是这一代的正确实现,它会自动生成盐并把盐、算法、代价参数一起编码进结果字符串。下面是当前推荐用法。

$hash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
// 存进数据库 varchar(255),长度给足,不要用 varchar(32)
$ok = password_verify($input, $row['password_hash']);

第三代是内存硬算法。bcrypt、scrypt、argon2 的共同点是「慢且吃内存」,攻击者想加速就必须付出同等内存代价,显卡的并行优势被大幅削弱。PHP 7.2 以后默认的 PASSWORD_DEFAULT 指向 bcrypt,PHP 7.4 起也可以显式选 PASSWORD_ARGON2ID。选哪一个都能接受,关键是别再用 md5。

cost 参数不是越大越好

bcrypt 的 cost 每加一,计算时间翻一倍。cost 10 在普通服务器上大约几十毫秒,cost 12 大约是其四倍。这个数字要按「登录接口能在多少并发下不被拖垮」来定,而不是按「越慢越安全」来定。一个可执行的判定方法是:取当前服务器上的实测单次耗时,乘以峰值登录并发,如果超过三成 CPU 就不合适。

$start = microtime(true);
password_hash('benchmark-pass', PASSWORD_DEFAULT, ['cost' => 12]);
$cost = microtime(true) - $start;
error_log("bcrypt cost=12 elapsed: " . round($cost * 1000) . " ms");

还有一点经常被忽略:password_verify() 一秒钟能校验的次数的倒数是天然的限流器。这句话反过来理解——如果你在登录接口前面没有做频率限制,攻击者打的就是这个「每次校验都要几十毫秒」的接口,一台机器就能把你的 CPU 占满。密码本身变慢了,不代表攻击变慢了,反而可能变成一种拒绝服务。

老用户迁移:不用强制重置也能升级

历史库里存着 md5 的用户怎么办?完全不必强制所有人重新设置密码。做法是在登录校验时「能过就顺手升级」。

$row = fetch_user_by_name($name);
$stored = $row['password_hash'];
$isLegacy = (strlen($stored) === 32 && ctype_xdigit($stored));
$ok = $isLegacy ? hash_equals($stored, md5($password))
                : password_verify($password, $stored);
if ($ok && $isLegacy) {
    $newHash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
    update_user_hash($row['id'], $newHash);   // 此时用户密码已通过校验,安全
}
if ($ok) { /* 建立会话 */ }

这段代码有三个必须注意的点。第一,比较用 hash_equals 而不是 ==,后者在 PHP 里遇到两个形如数字的字符串会做数值比较,还可能被时序攻击利用。第二,判断老格式靠长度加十六进制字符,长度 32 是 md5 的特征,成本极低。第三,升级必须发生在密码校验通过之后,此时明文密码在手,可以直接生成新哈希,用户毫无感知。整个迁移会随着用户登录逐步完成,几个月后跑一次统计,剩下的就是长期不登录的账号,那部分可以批量标记为需要重置。

第二层:会话与令牌

密码只解决「你是谁」,登录之后维持身份靠会话。PHP 默认的会话机制在自建站上最常见的三个错误是:会话 ID 在登录前后不更换、Cookie 没有加安全属性、会话有效期没有上限。

会话固定攻击的路径很具体:攻击者先用自己的浏览器访问站点拿到一个会话 ID,然后想办法让受害者用这个 ID 登录,登录成功后服务端把这个 ID 对应到了受害者的身份,攻击者手上那个 ID 就直接变成了被害者。防御方法只有一行:登录成功、权限发生变化时立刻调用 session_regenerate_id(true)。

if ($ok) {
    session_regenerate_id(true);
    $_SESSION['uid'] = $row['id'];
    $_SESSION['login_at'] = time();
    $_SESSION['ip_prefix'] = substr($_SERVER['REMOTE_ADDR'], 0, 9);
}
// php.ini / ini_set
session.cookie_httponly = 1
session.cookie_secure   = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
session.gc_maxlifetime  = 43200

四个配置项各有明确作用。httponly 让 JS 读不到 Cookie,XSS 就算成功也偷不走会话。secure 保证 Cookie 只走 HTTPS。samesite=Lax 挡住绝大多数跨站请求伪造。use_strict_mode 拒绝服务端未生成过的会话 ID,直接废掉会话固定的前半段路。最后 gc_maxlifetime 是会话的绝对生命上限,配合在会话里记录 login_at、超过时间强制重新登录,比「登录永不过期」要安全得多。

顺带说一句存储位置。默认的会话文件放在 /tmp,很多一键脚本顺手把 /tmp 设成 777 或者整个目录被定期清空,都会造成「随机掉登录」。正经做法是指定一个独立目录,权限 1733(粘滞位),并让 PHP 的会话清理只处理自己的目录。

第三层:第二道门怎么做

密码泄露这件事只能降低概率,不能消除。所以后台这类高价值入口,都该加第二道门。按侵入性从小到大排,有下面几条路。

第一条,IP 白名单。自建站的后台访问者通常只有站长自己,如果出口 IP 固定,直接在最外层 Nginx 上限制来源,成本最低、效果最好。移动办公或宽带动态 IP 的话,可以退而求其次:只对后台路径开启基础认证,把两串毫无关联的凭证叠在一起。

location ^~ /admin/ {
    allow 203.0.113.0/24;
    deny  all;
    auth_basic "restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;
}

第二条,TOTP 动态口令。手机上的验证器应用(不依赖短信,也就不依赖运营商)每三十秒生成一个六位码。服务端只需要保存一个共享密钥,校验时用同样的算法算一遍。PHP 实现只需要 HMAC-SHA1 和一个时间窗口判断。

function totp(string $secret, int $t = null): string {
    $t = $t ?: intdiv(time(), 30);
    $key = base32_decode($secret);
    $bin = pack('N*', 0) . pack('N*', $t);
    $hash = hash_hmac('sha1', $bin, $key, true);
    $off = ord($hash[19]) & 0x0f;
    $num = unpack('N', substr($hash, $off, 4))[1] & 0x7fffffff;
    return str_pad((string)($num % 1000000), 6, '0', STR_PAD_LEFT);
}

校验时不要用 ==,要用 hash_equals;同时允许前后各一个时间窗口,容忍手机时钟偏差三十到六十秒。另有一个务实的规定值得写进代码:同一个用户连续五次验证码错误就暂时锁定这段校验,并在日志里记一笔。没有这一条,六位数只有一百万种组合,配上不限次数的尝试就是可爆破的。

第三条,WebAuthn 通行密钥。这是目前最强的形态:浏览器用设备上的私钥签名,服务端只存公钥,没有任何可以偷走的共享密钥,也天然抵抗钓鱼(签名绑定域名)。代价是需要一个前端 JS 调用和一套注册/登录的挑战值流程,实现体量大概是 TOTP 的三倍。对个人站长来说,一个更省力的中间方案是把恢复代码做好:开启 TOTP 时生成十个一次性恢复码,用 bcrypt 存哈希,用完即销毁。手机丢了不至于把自己锁在门外。

第四层:那些看起来无害的判断

登录流程里最危险的往往不是算法选错,而是几行「顺手写」的逻辑。

用用户名是否存在来决定报错信息。「用户不存在」和「密码错误」分开提示,等于送给攻击者一个用户名枚举接口。统一返回「用户名或密码错误」,并且让两条路径的响应时间接近——用户不存在时也跑一次假的 password_verify,否则响应时间差本身就是信标。

$dummy = '$2y$12$abcdefghijklmnopqrstuvABCDEFGHIJKLMNOPQRSTUVWXyz01234';
$hash  = $row ? $row['password_hash'] : $dummy;
$ok    = password_verify($password, $hash) && $row !== null;

记住我令牌直接存用户 ID。「记住我」如果实现成「Cookie 里放 uid,下次自动登录」,等于把身份凭证明文放在客户端。正确做法是生成一个随机令牌,库里存它的哈希,令牌带过期时间和使用者标识(UA 指纹、登录 IP 段),并且在使用后立即轮换。一旦发现同一个令牌被使用两次,直接作废该用户全部令牌——这是检测凭证被盗用的标准信号。

把密码写进日志。调试时随手 error_log(print_r($_POST, true)),线上忘了删,密码就落进磁盘;Nginx 的 access log 如果记录了完整 query string,URL 里带 ?password= 的重置链接也会一起留下。日志脱敏的规则应该是「按字段名黑名单 + 白名单输出」,而不是靠人记得不打印。

登录接口没有限流。频率限制应该按三个维度同时做:单 IP 每分钟次数、单账号每小时失败次数、全局失败总数突增告警。前两个在 Nginx 和 PHP 层都能做,第三个需要一点日志聚合,但它才是唯一能发现「分布式撞库」的信号——攻击者换 IP 时前两道防线全失效。

小结:从今天就能做的四件事

如果只做四件事,按收益排:一,全站密码哈希换成 password_hash 并加老格式自动升级,这一步几乎无成本;二,登录成功后 session_regenerate_id(true),把四个 session 配置项补上;三,后台路径加一层来源限制或基础认证;四,给登录接口加频率限制并统一错误提示。这四步做完,绝大多数自动化扫描和撞库尝试都会失效。

进阶部分(TOTP、WebAuthn)不用一次到位,但要记得留接口:在用户表加两个可空字段,一个是 TOTP 密钥,一个是恢复码哈希集合,登录流程里预留一个「需要第二因素」的分支点。等到某天真的需要时,改动量就只有填实那段分支,而不是回头重构整条登录链路。安全这件事的性价比,永远在「提前留好结构」这一侧最高。

Last modification:October 31st, 2026 at 10:26 pm

Leave a Comment