为什么密码登录已经不够了
做个人站长这些年,最容易被忽视的一环就是后台登录安全。很多人给服务器装了防火墙、配了 fail2ban、关了密码登录改用密钥,却在网站后台、数据库面板、宝塔这类管理界面上继续用「用户名 + 密码」这一层防护。一旦这个密码在别处泄露过,或者被撞库命中,攻击者拿到的就是全部权限。
两步验证(2FA,Two-Factor Authentication)要解决的正是这个问题:即便密码被偷,攻击者手里还缺一个「只有你手上设备才能生成的动态码」。而在所有 2FA 方案里,TOTP(Time-based One-Time Password,基于时间的一次性密码)是最适合自建服务、成本最低、也最好落地的一种。
这篇文章讲清楚三件事:TOTP 到底是怎么算出那 6 位数字的;怎么在一台普通 Linux 服务器上给它加一层「第二道门」,无论是用 PAM 保护 SSH,还是保护自建后台;以及落地时最容易踩的几个坑——时间不同步、备份码丢失、replay 攻击。
TOTP 的原理:一道用时间当盐的 HMAC
很多人以为动态码是服务器随机生成再发给你的,其实恰恰相反:服务器和你的手机各自独立算出了同一个数字,中间从来没有传输过这个码。这就是 TOTP 安全的关键——码不走网络,只在最后一刻由你手动输入。
算法本身很简单,核心只有三步:
1. 计数器 C = floor(当前 Unix 时间戳 / 30)
——每 30 秒换一格,双方靠同一台时钟对齐
2. HMAC-SHA1(共享密钥 K, 计数器 C)
——K 是注册时服务器发给你 App 的一串 Base32 密钥
3. 动态截断(dynamic truncation)取 31 位 -> 对 10^6 取模 -> 补足 6 位
——所以每次输出都是 6 位十进制数字关键在于第 1 步:计数器只跟时间有关。只要服务器和手机的时钟误差在 ±30 秒以内,双方在第 30 秒这个窗口内算出来的 C 就是同一个值,于是 HMAC 结果也相同,最终 6 位码自然一致。服务器验证时通常会同时检查「上一格、当前格、下一格」三个窗口,这就是为什么你的验证码在剩下 1 秒时输入也能成功。
TOTP 属于 RFC 6238 标准,它依赖的共享密钥只存在于服务器和你的验证器 App 里,不需要联网、不需要短信、不会有短信延迟或运营商劫持的问题。这也是它比短信验证码安全得多的原因——短信可以被 SIM 卡劫持攻击拦截,TOTP 不会。
方案一:用 PAM 给 SSH 加 TOTP 第二道门
最典型的落地场景是保护 SSH 登录。虽然我们早就改用密钥登录,但密钥本身也可能被复制、也可能有口令过弱,叠加一层 TOTP 后,即便私钥泄露,攻击者还得拿到你手机上的动态码。
第一步,安装 Google Authenticator 的 PAM 模块。Debian/Ubuntu 下的包名是 libpam-google-authenticator:
apt-get update
apt-get install -y libpam-google-authenticator第二步,为当前用户生成密钥。运行 google-authenticator 命令,它会用 ASCII 二维码在终端里画出来,掏出手机上的 FreeOTP、Aegis 或 Google Authenticator 扫一下即可。过程中它会问你几个问题:
google-authenticator
Do you want authentication tokens to be time-based (y/n) y
Do you want me to update your "~/.google_authenticator" file? (y/n) y
Do you want to disallow multiple uses of the same authentication
token? (y/n) y # 防止同一动态码被重复使用
By default, a new token is generated every 30 seconds ...
Do you want to enable rate-limiting? (y/n) y # 限制暴力尝试次数第三个问题「disallow multiple uses」很关键,它让同一个动态码只能用一次;第四个问题开启限速后,每 30 秒最多 3 次尝试,能有效挡住在线暴力破解。
第三步,告诉 SSH 在认证时调用 PAM 模块。编辑 /etc/pam.d/sshd,在文件顶部加入一行:
# /etc/pam.d/sshd 文件最开始
auth required pam_google_authenticator.so nullok这里的 nullok 表示「如果用户还没配置 TOTP,就跳过这道验证」——这是为了给你留后路,等所有账号都配好之后应该去掉它,强制要求。第四步,修改 sshd 配置,让认证流程真的走 PAM 的挑战响应:
# /etc/ssh/sshd_config
KbdInteractiveAuthentication yes
UsePAM yes
# 如果你还想保留密码,可写:
# AuthenticationMethods publickey,keyboard-interactive
# 更推荐:必须密钥 + 动态码,两者都要
AuthenticationMethods publickey,keyboard-interactive最后用 sshd -t 检查配置语法,确认无误后 systemctl reload ssh 重载。注意:千万不要在没验证成功的情况下关掉当前会话再重连。正确的做法是另开一个终端窗口,尝试用密码方式登录测试,确认 TOTP 流程能走通、能进去,再关闭旧连接。否则配置写错会导致自己被锁在门外——这个坑非常多人踩过。
方案二:保护自建后台登录
如果你用的是宝塔、phpMyAdmin、自建的管理后台,它们大多不支持直接接 SSH 那套 PAM(除非你给整台机器的登录加验证)。一个通用做法是在 Web 层自己实现 TOTP 验证。以 PHP 为例,安装一个纯 PHP 实现的 TOTP 库即可:
composer require spomky-labs/otphp核心验证逻辑大致如下,思路就是「服务器用同一密钥算出预期码,再和用户输入的比对,允许前后一个时间窗口」:
use OTPHP\TOTP;
// 登录成功回调里,进入第二道验证
$totp = TOTP::create($userSecret); // 注册时存进数据库的 Base32 密钥
$totp->setPeriod(30);
if ($totp->verify($userInputCode, null, 1)) {
// 1 表示允许前后各一个 30 秒窗口,容忍轻微时钟漂移
$_SESSION['mfa_passed'] = true;
} else {
// 连续错误计数 + 锁定
logAttempt($user, 'totp_fail');
}管理员首次绑定时,服务器生成一个 Base32 密钥存库,然后把它渲染成二维码给用户扫。这里有个安全细节:二维码里包含的是完整密钥,谁扫到谁就能生成动态码,所以绑定页面必须只对已登录用户开放,且绑定时长要有限制,绑定成功后立刻作废二维码。
绑定成功后,一定要生成一组「一次性恢复码」(recovery codes)交给用户保存。手机丢了、验证器卸载了、系统重置了,恢复码是唯一的救生索。恢复码要一次性使用并且加密存储,不能明文扔进数据库。
落地时最容易踩的四个坑
坑一:服务器时间漂移导致动态码永远不对
TOTP 完全依赖时间对齐。便宜的 VPS、被暂停又恢复的机器、虚拟机时钟不同步,都会让服务器时间跑偏。判断方法很简单,看时间偏移量:
timedatectl status
# 关注 System clock synchronized: yes
# 以及偏移是否可忽略
# 手动同步一次
apt-get install -y systemd-timesyncd
timedatectl set-ntp true
systemctl restart systemd-timesyncd如果 System clock synchronized 是 no,或者偏移几十秒,TOTP 必然验证失败。这也是「今天还能登、明天就登不上」类故障的头号原因。建议在验证逻辑里显式接受 ±1 个窗口,给运维留一点缓冲。
坑二:没有备份恢复码,手机一丢就彻底锁死
这是最惨的事故。密钥只存在手机里,手机摔了、刷机了、换机没迁移,你就再也算不出正确的动态码。除了恢复码,另一个好习惯是把密钥(那串 Base32 字符串)抄写在离线的地方,或者用密码管理器存一份。注意:千万别把密钥截图后放进相册随便同步到云,那等于把第二因素的钥匙也交给云端了。
坑三:忽略了同一动态码可被重放
生成的 6 位码在 30 秒窗口内是同一个值。如果程序不做校验,攻击者通过键盘记录或中间人拿到这个码后,在它过期前可以再用一次。前面 google-authenticator 里那个「disallow multiple uses」就是防这个。自己实现时也要记录「最近用过的码」,拒绝重复使用。
坑四:把 TOTP 当成了唯一防线
TOTP 是第二因素,不是替代品。它防的是「密码泄露」,但防不住服务器本身被入侵、防不住会话被劫持、也防不住钓鱼——钓鱼页面可以实时转发你的动态码。所以 TOTP 要和强密码、HTTPS、fail2ban、最小权限这些基础措施配合使用,而不是互相替代。把它看作「安全拼图里的一块」,而不是「安全问题的终极答案」。
一个真实场景:上线 TOTP 后遭遇的时钟事故
去年给一台主力 VPS 加 SSH TOTP 时遇到过一个典型事故。配置全部写完、验证也通过了,第二天一早却怎么也登不上,输入动态码连续失败,连着三次后被限速锁了十分钟。一开始怀疑是手机时间不准,换了另一台设备扫码生成,同样失败。
排查动作很直接:另开一个带 VNC/IPMI 的带外通道登进去,执行 date -u 和手机上的 UTC 时间一比,发现服务器时钟慢了整整 47 秒——刚好超过 30 秒窗口,落在「上一格」之外,而校验只允许前后各一格。根因是这台机器从暂停状态恢复后,systemd-timesyncd 没有自动跑起来,时钟一直停在旧值。修复是 timedatectl set-ntp true 并重启同步服务,同时把验证窗口从 ±1 放宽到 ±1(本身没错),但更重要的是加了一条监控检查:每次登录脚本里顺手 timedatectl status 看一下同步状态,同步异常直接告警。
这件事的教训是:TOTP 的可靠性 100% 取决于时钟的可靠性。部署前先确认 NTP 同步正常,部署后把时钟同步纳入监控,比事后排查省心得多。前后对比也很明显:事故前没人在意时钟,事故后把「时间同步是否正常」当成和磁盘、内存一样的必看指标,之后再没出过类似问题。
总结
TOTP 不是黑魔法,它的本质就是「共享密钥 + 时间计数器 + HMAC」,双方各自算出同一串 6 位数字。它便宜、离线、无短信依赖,是个人站长能给自己服务加的最划算的一道防护。
落地路径有两条:想保护 SSH,就走 PAM 那条线,注意 nullok 和 AuthenticationMethods 的写法,改完配置一定要另开会话验证;想保护后台,就在 Web 层用现成库实现,重点是绑定二维码别外泄、恢复码必须生成并离线保存。最后记住四个坑:时钟漂移、没有恢复码、动态码可重放、把 2FA 当唯一防线。
把 TOTP 配好之后,你会发现后台登录的整个风险模型都变了——攻击者就算撞对了密码,也依然进不来。对个人站长来说,这种「多花半小时、长期安心」的投入,回报率很高。