我最早把这套登录体系从"一条if"重构出来,是因为一次不算高深但很疼的撞库事故。那个后台系统没有多因素认证、没有风控拦截,甚至连会话一致性都没做——同一个账号可以在浏览器、手机、同事电脑上同时登录,互相不干扰。出事那晚,监控里全是登录失败的日志,几百个密码轮番尝试同一个管理账号,接口直接被打到502。我盯着日志想了很久,光加一个验证码不够,得把登录这件事重新拆开,一点一点解牛。
这篇文章记录的是我用PHP从零到一实现多因素认证、风控拦截、会话一致性登录的全过程。不打算直接扔一个开源包让你跑完就完事,而是把每一步背后的选择、坑、误判都摊开。适合那些已经在做PHP后台项目、想给登录模块认真补安全的开发者。如果你只想给登录框加个滑块验证码,可以关掉了;如果你想知道一个登录请求从提交到放行中间到底该经历哪些关卡,这篇文章应该能给你一些参考。
1. 一次撞库事故与登录体系的三块拼图
1.1 事故复盘
那个后台登录接口的原始逻辑很简单:接收用户名和密码,查数据库比对,对就写入Session,跳转后台。没有频控,没有验证码,没有二次校验。撞库攻击者拿到的不是我的数据库,而是其他网站泄露的密码库,用同样的邮箱和密码批量来试。我们的运营同事习惯用一套密码注册所有平台,命中率远超预期。
复盘时我列了三个缺口。第一,多因素认证完全缺失,密码一旦泄露就等于账号被接管;第二,没有风控拦截,同一IP短时间内试几百个账号这种明显异常,接口连眉头都不皱;第三,会话一致性没有约束,攻击者只要拿到一个有效Session就可以长期挂着,合法用户的登录状态也不会把它踢掉。三个缺口叠加在一起,就是一场迟早要来的事故。
1.2 登录请求链路里,三块拼图应该站在哪
重构前我画了一张链路图,不用画得很高级,就是登录请求从进入到放行的顺序:
客户端提交账号密码 -> 密码校验 -> 风控评分 -> 多因素认证 -> 签发会话令牌 -> 进入应用
注意我把风控放在密码校验之后、多因素认证之前。原因是风控主要识别"这个请求是不是人肉或脚本在撞库",它需要基于密码校验的结果作为上下文。密码都错了,直接进入频控计数,根本不需要走到后面的动态口令环节。多因素认证放第三层,只有当密码正确且风控评分在可接受范围内时才触发,避免让随机脚本白白消耗短信或动态口令资源。
会话一致性放在最后,因为签发令牌这个动作决定了后续登录状态由谁控制。整个登录链路不是四个if嵌套的堆砌,而是一个过滤漏斗:每通过一层,可疑流量就少一批。
1.3 为什么坚持用PHP从零到一
说实话,市面上有现成的多因素认证库、风控SDK、会话管理组件。但我仍然决定用PHP写一套,有两个原因。第一,这个项目的技术栈是PHP 8.3 + Redis + MySQL,团队不准备引入独立的身份认证服务,所有逻辑要落在现有业务代码里,自研是最少依赖的方式。第二,安全组件不能是黑盒,TOTP怎么生成、风控规则怎么打分、并发登录怎么踢线,这些细节如果不在自己的代码里,出了问题连排查方向都没有。
"从零到一"不代表所有轮子都自己造。TOTP算法我一开始就决定手写,是为了彻底理解RFC 6238;二维码生成、IP归属地查询这种非核心功能我会用成熟组件。这个判断标准很简单:凡是安全关键路径上的逻辑,必须看得懂;凡是纯工具性质的辅助能力,拿来主义反而更稳。
2. 第一块拼图:多因素认证(MFA)的TOTP落地
2.1 选型:为什么先在短信验证码和TOTP之间选了TOTP
多因素认证的第一反应往往是短信验证码,毕竟用户熟悉,接入成本看起来也低。但实际算账之后我放弃了。短信验证码依赖服务商的到达率和时延,每发一条都要钱,更重要的是,短信通道本身可能被劫持或拦截。如果验证码只依赖手机号这一个通道,那"多因素"的安全增益会被削弱。
TOTP(基于时间的一次性密码)是更好的第一步选择。它不依赖短信通道,用户在Google Authenticator、1Password这类软件里绑定密钥之后,每30秒生成一个6位动态码,完全离线验证。哪怕攻击者拿到了密码,没有用户的认证器App也过不了第二关。而且TOTP是开放标准,后续就算想固化到硬件密钥,思路也是同一套。
2.2 表结构设计与密钥加密存储
我在用户表上加了几个字段:
ALTER TABLE users ADD COLUMN mfa_secret VARBINARY(255) NULL COMMENT 'AES-GCM加密的TOTP密钥', ADD COLUMN mfa_enabled TINYINT(1) NOT NULL DEFAULT 0, ADD COLUMN mfa_confirmed_at DATETIME NULL;用户绑定MFA时,生成一个随机TOTP密钥,用AES-256-GCM加密后存入mfa_secret,密钥本身放在环境变量里,不进代码仓库。这里要解释一下,为什么服务器端还要加密存储:数据库被拖走是很大的威胁,如果数据库里的密钥明文泄露,攻击者可以直接生成动态码。加密后虽然应用服务器如果有权限也能解开,但至少多了一层隔离,也让运维规范上明确区分了"数据库泄露"和"服务器完整沦陷"两级风险。
另外建了一张恢复码表。
CREATE TABLE user_recovery_codes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, code_hash CHAR(64) NOT NULL, used_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_code (user_id, code_hash) );恢复码在用户绑定MFA时一次性生成10个,明文只在页面上展示一次,数据库只保存哈希值。这跟保存密码的思路一样,数据库泄露也不怕恢复码被批量使用。
2.3 手写RFC 6238:TOTP生成与校验代码
核心算法是HMAC-SHA1加上动态截断。我先写了一个Base32解码函数,因为TOTP密钥通常以Base32格式提供:
function base32Decode(string $input): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $input = strtoupper($input); $buffer = 0; $bits = 0; $output = ''; for ($i = 0; $i < strlen($input); $i++) { $value = strpos($alphabet, $input[$i]); if ($value === false) { throw new InvalidArgumentException('Invalid Base32 character'); } $buffer = ($buffer << 5) | $value; $bits += 5; if ($bits >= 8) { $output .= chr(($buffer >> ($bits - 8)) & 0xFF); $bits -= 8; } } return $output; }然后是生成动态码:
function getTOTP(string $secret, ?int $timestamp = null): string { $timestamp = $timestamp ?? time(); $timeSlice = intdiv($timestamp, 30); $key = base32Decode($secret); // RFC 6238 要求的64位大端计数器,前32位补0,当前时间戳小于2^32, // 在2106年之前这段代码都不会溢出,够用了。 $counter = pack('N2', 0, $timeSlice); $hash = hash_hmac('sha1', $counter, $key, true); // 动态截断:取哈希最后一个字节的低四位作为偏移量 $offset = ord($hash[strlen($hash) - 1]) & 0x0f; $binary = unpack('N', substr($hash, $offset, 4))[1] & 0x7fffffff; return str_pad((string) ($binary % 1000000), 6, '0', STR_PAD_LEFT); }我之前用时间戳除以30取整作为计数器,这个概念很像一个"动态验证码的节拍器",每30秒跳一下。校验时不能只校验当前节拍,因为用户在输入动态码的过程中可能刚好跨过了时间的边界。所以我校验三个节拍:前一个、当前、后一个,用数组循环比较,只要有一个相等就放行。
校验通过后,还有个必须做的动作:消费这个验证码,防止同一时间片内重复使用。我后面会在第五节专门讲这个坑。
2.4 防绕过、防重放、防锁死的边界设计
TOTP不是加个验证框就完事,边界条件才是真正的安全设计。
第一,防绕过。用户虽然绑定了MFA,但要允许他记住当前设备一段时间,否则每次登录都输入动态码,体验太差。我用一个单独的Cookie存mfa_trust_hash,这个哈希是服务端下发的签名值,不是随便一个Cookie就能顶用。而且信任周期建议最多30天,新设备初始化之后会看到设备确认页。
第二,防暴力尝试。MFA校验接口必须有速率限制,同一个用户连续5次验证失败,就锁定该账号的MFA校验10分钟。攻击者拿到密码后,即使能进入第二步,也只有5次机会去猜6位动态码。
第三,防锁死。如果用户手机丢了,恢复码要能救场。恢复码一次性使用,一旦检测到使用恢复码登录,系统强制要求重新绑定新密钥,并撤销所有已信任设备。这个流程我称之为"安全重置",在用户端展示为"重置两步验证"。
3. 第二块拼图:风控拦截规则引擎
3.1 风控不是在登录框加个验证码,而是给请求打分
很多人以为风控就是加滑块验证码,错。滑块验证码只是风控的"执行动作"之一,真正的风控是一个评分过程。每个登录请求会携带一组上下文信息,系统根据这些信息命中一系列规则,累加风险分数,然后根据分数决定放行、进入二次验证还是直接封锁。
我把风控设计成"先评估、后处置",而不是某个规则一命中就一票否决。原因很实际:单一规则误杀率太高。比如"IP归属地突然变化"这条规则,用户出差到另一个城市就会被命中,单独拿出来封锁会让人崩溃。但如果这个变化同时伴随"这个设备指纹近1小时关联了5个账号",两条规则的分数叠加,才值得警惕。
3.2 采集端:设备指纹、IP与行为序列
风控需要输入数据。我在登录页接入了一段前端脚本,采集三类信息:
- 设备指纹:Canvas绘制特征、WebGL渲染器信息、屏幕分辨率、时区、语言的哈希值。
- IP与地理位置:IP是服务端从连接获取的,再通过IP库解析出城市和运营商。
- 行为序列:从页面加载到点击登录按钮的时间间隔、密码框输入速度、鼠标移动轨迹。
这里必须注意合规。我在采集说明文案里明确写了"仅用于登录安全验证",并且不采集cookies之外的可识别个人身份信息。设备指纹的哈希值是不可逆的,服务器不存储原始Canvas数据。坦白说,国内对这块的法律要求越来越严格,做的时候一定要咨询法务,别为了安全把隐私合规丢了。
服务端拿到这些信息后,合并成类似这样的上下文数组:
[ 'user_id' => 1024, 'ip' => '203.0.113.10', 'ip_city' => 'Shanghai', 'device_fp' => 'a3f1c9d0e2b44f1b8a6c', 'user_agent' => 'Mozilla/5.0 ...', 'input_duration_ms' => 4820, ]3.3 规则引擎实现:数据库配置+Redis计数器
规则引擎的核心是一张规则表。
CREATE TABLE risk_rules ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, rule_code VARCHAR(50) NOT NULL, rule_name VARCHAR(100) NOT NULL, weight INT NOT NULL DEFAULT 10, trigger_meta JSON NOT NULL, status TINYINT NOT NULL DEFAULT 1 );规则的解释逻辑写在PHP代码里,规则参数存在trigger_meta中。例如"同一IP短时间登录失败次数过多"这条规则,trigger_meta里放的是时间窗口和次数阈值:
{ "window": 300, "threshold": 10, "redis_key": "risk:login_fail:{ip}" }登录密码校验失败后,不管账号存不存在,我都执行一次INCR并设置过期时间,然后读取当前计数:
$redisKey = 'risk:login_fail:' . $ip; $count = $redis->incr($redisKey); if ($count === 1) { $redis->expire($redisKey, 300); }规则引擎依次遍历启用的规则,把命中的规则的weight累加起来。我设了三个风险等级:
| 风险分数 | 处置动作 |
|---|---|
| 0-39 | 放行 |
| 40-79 | 进入MFA或滑块验证 |
| 80-100 | 直接拦截,写入风控日志 |
这个打分是可配置的。运营可以在后台调整权重,甚至临时下线某条规则,全部不用改代码,只要改数据库和缓存。
3.4 误杀与降级:风控系统必须能一键熔断
风控系统最怕的不是漏杀,而是误杀把正常用户全挡在门外。所以我把处置动作设计成"渐进惩罚"而不是"终身封禁"。IP临时锁定只持续30分钟,设备指纹锁定12小时,账号则必须有运营后台的人工审核按钮。
还有一个必须考虑的场景:Redis不可用。风控规则主要依赖Redis计数器,如果Redis挂了,风控等于没有眼睛。我的处理策略是提供一个熔断开关,开关在配置中心里。正常情况下fail_closed=true,Redis不可用时直接拒绝高风险来源(其实不知道风险,就直接拒绝所有登录请求),但这会造成线上故障。所以在运维和开发共同确认Redis故障后,把开关切到fail_open=false,登录请求只校验密码和MFA,不做风控评分。这个降级决策会写一条审计日志,事后很容易复盘。
4. 第三块拼图:会话一致性登录
4.1 会话一致性不等于单点登录,但更贴合业务
网上很多人把这类签到"会话一致性"等同于单点登录(SSO),其实不是一回事。SSO解决的是"多个系统共用一套登录凭证",而会话一致性解决的是"同一个账号在任意时刻只能有一个活跃会话"。放在我们这个后台场景里,业务要求就是:新设备登录后,旧设备立刻失效,不允许两个地方同时拿同一个账号操作。
用生活类比解释一下,普通登录像借了很多把钥匙,每个设备一把,各进各的门;会话一致性登录像只有一把钥匙,谁最后拿到,前面的全部作废。这个设计对高权限后台账号尤其重要,防止运维账号在多个终端遗留会话。
4.2 会话存储选型:为什么不用PHP默认Session
PHP自带的Session默认存储在本地文件里,对多实例部署完全不友好。Nginx后面挂着多台PHP-FPM,用户第一次请求落在A机器写入Session,第二次请求被负载均衡分到B机器,B机器读不到文件,用户就被登出了。
我全面转向Redis保存会话。会话数据结构用两个Key:
session:token:{token_hash}:这个Key的值是user_id,过期时间设为30分钟。session:user:{user_id}:这个Key的值是当前活跃的token_hash。
这里存的是token的哈希值,不是原始token,这样即使Redis被脱库,攻击者也没法直接拿哈希值去冒充会话。原始token只在用户浏览器的HttpOnly Cookie里存在,每次请求过来我先哈希,再拿到Redis里去查。
4.3 Lua脚本实现原子"顶号"
最难的是并发场景。想象一下,用户同时打开两个浏览器标签页,两个标签页同时发起登录请求,后端都通过了密码、风控、MFA,然后都在写Redis。如果没有原子操作,两个请求很可能都创建了自己的token,session:user:{user_id}这个Key会被后写的一方覆盖,但先写的一方的session:token:{token_hash}还存在,造成两个会话明明都还活着,代码逻辑里却认为只有一个。
解法是用Redis的Lua脚本把"踢旧Token + 写新Token"做成一个原子操作:
local oldTokenKey = KEYS[1] -- session:user:{user_id} local newTokenHash = ARGV[1] local userId = ARGV[2] local ttl = tonumber(ARGV[3]) local oldTokenHash = redis.call('GET', oldTokenKey) if oldTokenHash then redis.call('DEL', 'session:token:' .. oldTokenHash) end redis.call('SET', oldTokenKey, newTokenHash) redis.call('SET', 'session:token:' .. newTokenHash, userId, 'EX', ttl) return 1在PHP里这样调用:
$script = <<<'LUA' ...上面的Lua脚本... LUA; $redis->eval($script, ['session:user:' . $userId, $newTokenHash, $userId, 1800], 1 // KEYS[1]的数量 );eval会保证脚本在Redis内部原子执行,并发情况下只有一个请求能最终成为活跃会话。旧设备收到下一次请求时,拿它的token去查session:token:{token_hash}会查不到,就知道自己被顶掉了,前端跳转到登录页。
4.4 会话固定攻击与滑动过期
登录成功之后,我必须确保服务端签发的token与登录前客户端持有的任何Cookie无关。PHP里原来的session_regenerate_id(true)干的就是这件事。换成自研会话后,我在登录成功的逻辑里生成一个全新的随机token,并且立即删除登录期间可能创建的临时Session Cookie,不让攻击者有机会把自己的Cookie价值植入用户会话。
Cookie本身设置了HttpOnly、Secure、SameSite=Lax。这些属性不是可选项,尤其是HttpOnly,它保证脚本无法读取Cookie,XSS攻击拿到会话令牌的难度大幅上升。
会话有效期我用滑动过期:只要用户30分钟内有一次活跃请求,token的过期时间就往后推30分钟;如果30分钟没有活跃,就强制登出。这个策略比固定30分钟到期的体验更好,比永久不过期的安全性高得多。每次活跃时更新过期时间,实际上是一次昂贵的Redis写操作,所以我在DB里存了一个last_seen字段,在后台页面刷新时才更新Redis TTL,而不是在每个接口请求里都刷。
5. 联调时踩过的大坑与压测曲线
5.1 TOTP容错窗口被当作重放窗口的教训
上线第一天就被测试友军捅出一个问题。我的校验函数接受了前后各一个时间片,理论上同一个动态码在三个时间片内都能通过。测试拿上一个节拍的动态码,在同一时间片内连续提交了三次,全部成功。
当时我愣了下,反应过来:校验通过之后没有"消费"验证码。TOTP算法本身是无状态的,同一个时间片+同一个密钥会生成同一个6位数字,所以必须用服务端状态记录"这个用户在这个时间片内的验证码已经用过了"。
我加了一个Redis Key:
$key = 'mfa:consumed:' . $userId . ':' . $timeSlice; $ok = $redis->set($key, '1', ['NX', 'EX' => 90]); if (!$ok) { throw new AuthException('验证码已使用,请等待下一个动态码'); }NX保证只有第一次请求能设置成功。这个Key的过期时间设为90秒,覆盖前中后三个时间片的跨度,保证这个动态码在有效期内只能被正确使用一次。改完这个问题,我才敢说MFA这关是真的防重放的。
5.2 并发登录场景下的竞态问题
会话一致性的Lua脚本解决了一个问题,但联调时又冒出一个新问题。Lua脚本里的oldTokenHash可能会在脚本运行时已经过期的,但Redis执行脚本时不会自动检查过期时间;即使过期了,GET依然返回nil,那旧token的session:token:{old}可能已经过期被清理了,问题不大。
真正的问题是,我把session:user:{user_id}的存在当成活跃会话的判断依据,依赖Lua脚本里SET之后没有设置过期时间,这个Key永远不会过期。如果用户以后再也不登录,Redis里就一直留着这个Key。虽然有session:token的TTL兜底,但脏数据会越积越多。后来我在Lua脚本里给session:user:{user_id}也设置了跟token一样的TTL,并依赖每次活跃请求刷新,相当于让整个活跃映射随时间自然消失。
5.3 一次登录请求到底消耗多少资源
我在联调环境用Apache Bench做了简单压测,因为登录链路涉及的MySQL和Redis操作比较多,我一开始预期QPS会很低。实测下来,在单台4核8G的实例、PHP-FPM固定200进程的情况下,关闭滑块验证之后的登录接口大概能跑到1200 QPS到1800 QPS,瓶颈不在TOTP计算,而在每次登录时的MySQL账号查询和后面的Redis读写。
拆开看一次完整登录请求,大概发生了:一次MySQL查询用户主记录,一次TOTP计算,三次Redis操作(频率计数、MFA消费标记、会话令牌创建),以及至少一次风控日志写入。如果把风控日志同步写入数据库,QPS能再掉30%。所以我把风控日志和登录日志全部丢进Redis队列,由异步Worker定期落库。登录接口只关心Redis队列是不是写成功了,数据库的压力从请求路径里挪出去,稳定性提升很明显。
5.4 日志追踪和告警:没有可观测性就别谈安全
这次重构让我养成一个习惯:安全模块的日志必须比业务日志更啰嗦。
我的登录日志里记录了每一次登录尝试的完整链路:用户名、IP、设备指纹、命中规则列表、风险分数、风控处置动作、MFA是否通过、最终是否成功、耗时。这些日志写进一张login_audit_log表,索引建在user_id和created_at上。
告警规则也简单直接:
- 同一账号10分钟内登录失败超过5次,预警。
- 风控系统拦截率达到当前时间段基线的3倍以上,预警。
- 某一IP触发多条高权重规则,预警。
有一次凌晨两点告警响了,我一看是某个测试脚本在循环遍历弱口令字典,虽然撞库没有成功,但风控在日志里已经把攻击路径回放得清清楚楚。没有这套日志,所谓安全加固就是摸黑打蟑螂。
6. 这次重构给我留下的三个操作习惯
6.1 安全功能的开关要像灭火器一样容易够到
重构过程中我反复删改规则,最后发现真正重要的不是"规则能跑多深",而是"规则错了能不能快速关掉"。我现在把MFA强制开关、风控熔断开关、主动会话失效开关全部做进了管理后台,任何开关操作都带审计日志。安全功能不能是一堆只能改代码才能动的死逻辑,否则一旦误杀,运维只能干瞪眼。
6.2 日志要能支撑事后回放
事故复盘时最怕的是数据不全。现在我的登录日志不仅记录"成功/失败",还把当时的IP、设备指纹、命中规则、风险分数全部记录在案。风控拦截日志甚至会保留原始请求体的摘要,这样即使有人恶意从某个IP大量试密码,我也能通过搜索IP或设备指纹把整个攻击路径拉出来。日常用不上,一旦用上就是救命级别的信息。
6.3 接下来可以继续做的方向
这次做完了TOTP和规则引擎,我给自己列了三个后续方向:第一是WebAuthn硬件密钥接入,让动态口令再进一步升级为物理密钥;第二是风控规则引入机器学习评分,不再只靠固定权重叠加;第三是把登录日志的异步处理改造成独立的数据管道,方便安全团队做更长期的分析。每一个方向都比现在更复杂,但至少这次,地基已经是一块一块揭开来夯实过的了。