
我最早把这套登录体系从一条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 6238TOTP生成与校验代码核心算法是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_closedtrueRedis不可用时直接拒绝高风险来源其实不知道风险就直接拒绝所有登录请求但这会造成线上故障。所以在运维和开发共同确认Redis故障后把开关切到fail_openfalse登录请求只校验密码和MFA不做风控评分。这个降级决策会写一条审计日志事后很容易复盘。4. 第三块拼图会话一致性登录4.1 会话一致性不等于单点登录但更贴合业务网上很多人把这类签到会话一致性等同于单点登录SSO其实不是一回事。SSO解决的是多个系统共用一套登录凭证而会话一致性解决的是同一个账号在任意时刻只能有一个活跃会话。放在我们这个后台场景里业务要求就是新设备登录后旧设备立刻失效不允许两个地方同时拿同一个账号操作。用生活类比解释一下普通登录像借了很多把钥匙每个设备一把各进各的门会话一致性登录像只有一把钥匙谁最后拿到前面的全部作废。这个设计对高权限后台账号尤其重要防止运维账号在多个终端遗留会话。4.2 会话存储选型为什么不用PHP默认SessionPHP自带的Session默认存储在本地文件里对多实例部署完全不友好。Nginx后面挂着多台PHP-FPM用户第一次请求落在A机器写入Session第二次请求被负载均衡分到B机器B机器读不到文件用户就被登出了。我全面转向Redis保存会话。会话数据结构用两个Keysession: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。如果没有原子操作两个请求很可能都创建了自己的tokensession: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、SameSiteLax。这些属性不是可选项尤其是HttpOnly它保证脚本无法读取CookieXSS攻击拿到会话令牌的难度大幅上升。会话有效期我用滑动过期只要用户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硬件密钥接入让动态口令再进一步升级为物理密钥第二是风控规则引入机器学习评分不再只靠固定权重叠加第三是把登录日志的异步处理改造成独立的数据管道方便安全团队做更长期的分析。每一个方向都比现在更复杂但至少这次地基已经是一块一块揭开来夯实过的了。