拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP登录安全实战:TOTP多因素认证、风控拦截与Redis会话一致性

PHP登录安全实战:TOTP多因素认证、风控拦截与Redis会话一致性

前阵子接手一个老 PHP 电商项目,老板让我把登录安全做扎实。当时我对多因素认证、风控拦截、会话一致性这三块也只是有个大概认知,网上现成的库又不敢直接塞进生产环境,干脆从零开始写一套。正好手上有个 PHP 8.3 的空闲服务,配合 Redis 直接把整条认证链路捋了一遍。这篇文章就用“庖丁解牛”的方式,把登录体系拆成三块骨头——多因素认证怎么做、风控拦截怎么评分、会话一致性怎么保证——每一块我都会给代码、给思路、给踩坑记录。内容偏实操,适合那些有 PHP 基础、但还没完整构建过认证体系的开发者,看完可以直接照着落地,也能理解每个设计决策背后的原因。

1. 项目解牛:三个模块为什么必须放一起做

很多人会把“多因素认证”“风控拦截”“会话一致性”当成三个独立需求分开做,这是非常容易踩坑的地方。用户的登录行为是一条完整链路,三者的关系是前后串联的:请求先进入风控引擎,风控决定你要不要做二次认证,二次认证通过之后再建立会话。拆开做,最终联调时一定会出现接口对不齐、状态不同步的问题。所以从设计第一天起,就要把它们当成一个闭环看。

1.1 先捋清楚需求的三条主线

多因素认证解决的是“密码泄露”问题。就算用户的账号密码被钓鱼、撞库撞出来了,没有第二个因素,攻击者依然进不来。我这边选了 TOTP(基于时间的一次性密码)作为主认证方式,因为在没有服务商依赖的前提下它完全离线可用,客户不用等短信,也不会被通道延迟坑。

风控拦截解决的是“碰撞与自动化攻击”问题。密码错误 100 次和密码错误 1 次,两者风险完全不一样。风控引擎就是在正式认证前给这次请求打分,分数决定走什么处置策略。它的价值不仅在于防黑客,还能防止正常用户被暴力破解影响。

会话一致性解决的是“登录态在多机、多端下的漂移”问题。生产环境至少两台 Web 服务器,如果 Session 存在本地文件里,负载均衡一转发,用户就被“强制下线”了。把 Session 搬到 Redis 之后,不管请求打到哪台机器都能读到同一份登录数据。

1.2 技术选型与目录结构

我全程用的是 PHP 8.3 + Redis 7 + nginx,路由自己写,不依赖重型框架。核心逻辑全部用原生 PHP 实现,二维码生成用endroid/qr-code,其他的能省则省。为什么要刻意去掉框架?因为框架的封装会把很多关键机制藏起来,比如 Session 生命周期、Cookie 属性、序列化方式,这些恰恰是登录体系最容易出问题的地方。用原生实现一遍,你能清楚看到每一步发生了什么。

项目目录结构大致长这样:

auth-system/ ├─ public/ │ └─ index.php ├─ app/ │ ├─ Auth/ │ │ ├─ Totp.php │ │ ├─ OneTimeCode.php │ │ ├─ RiskEngine.php │ │ └─ SessionManager.php │ ├─ Support/ │ │ ├─ Base32.php │ │ └─ RedisClient.php │ └─ Config.php ├─ composer.json └─ .env

每个类的职责很纯粹:Totp处理时间同步验证码,OneTimeCode负责短信/邮件验证码和备份码,RiskEngine做信号采集和风险评分,SessionManager统一接管 Redis 里的会话读写。这种职责划分也方便以后替换实现,比如风控规则想升级成机器学习打分,只需要改RiskEngine内部逻辑。

1.3 一次登录请求的完整生命周期

把流程在脑子里过一遍,后面所有代码都是给这个流程服务的:

  1. 用户提交账号密码,服务端先校验账号是否存在、密码哈希是否匹配。
  2. 密码校验通过后,立刻进入风控引擎。风控会采集当前设备的指纹、IP 地理位置、User-Agent、历史失败次数等信号。
  3. 风控引擎输出风险评分。0 到 35 分直接放行,35 到 70 分要求二次认证,70 分以上直接拒绝。
  4. 如果需要二次认证,就触发 TOTP 或短信验证码流程,用户提交一次性验证码。
  5. 认证通过后,调用session_regenerate_id(true)重建会话,把用户身份和登录时间写入 Redis,并下发 Cookie。
  6. 用户后续请求会带着 Session ID,Redis 里读出来,校验是否过期、是否被强制下线,然后做滑动续期。

这个链路里,风控在认证之前,认证在会话建立之前。顺序不能反——如果先建会话再做风控,风控一旦把用户拦截,会话状态就会出现半登录半匿名的尴尬局面。

2. 多因素认证:从 TOTP 到验证码的完整链路

多因素认证这块,我推荐优先做 TOTP。它不依赖短信通道,用户装一个 Google Authenticator、Microsoft Authenticator 或者 1Password 就能用。TOTP 的完整落地包含四个部分:核心算法、密钥绑定、校验逻辑、兜底方案。下面逐个拆。

2.1 TOTP 的核心算法:HMAC 与时间窗口

TOTP 的本质不是加密,而是“用时间和密钥计算一个动态短码”。它的底层是 HOTP(HMAC-Based One-Time Password),区别只在于 HOTP 把计数器传进去,TOTP 把当前时间戳整除 30 得到的结果当作计数器。为什么是 30 秒?这是 RFC 6238 推荐的默认时间步长,太长用户等得烦躁,太短用户输入时间不够。

核心计算代码我用 PHP 实现,关键点都注释了:

final class Totp { public const STEP = 30; public function __construct( private readonly string $secret, // base32 解码后的原始密钥字节 private readonly int $digits = 6, private readonly int $window = 1, ) {} public function codeAt(float $timestamp): string { // 当前时间步长 $counter = intdiv((int) floor($timestamp), self::STEP); // 计数器必须是 8 字节大端序,RFC 4226 里高位补零 $bin = pack('N2', 0, $counter); // HMAC-SHA1,输出 20 字节 $hash = hash_hmac('sha1', $bin, $this->secret, true); // 动态截断:取 hash 最后一个字节的低 4 位作为偏移量 $offset = ord($hash[19]) & 0x0f; // 从偏移量开始取 4 字节,最高位强制为 0(避免符号位干扰) $value = ((ord($hash[$offset]) & 0x7f) << 24) | ((ord($hash[$offset + 1]) & 0xff) << 16) | ((ord($hash[$offset + 2]) & 0xff) << 8) | (ord($hash[$offset + 3]) & 0xff); // 取模得到 6 位数字,不够前面补零 return str_pad((string) ($value % 10 ** $this->digits), $this->digits, '0', STR_PAD_LEFT); } }

有两点我想特别说明。第一,pack('N2', 0, $counter)这种写法专门兼容 32 位 PHP,把高 32 位置零,低 32 位存计数器,不会因为$counter超出整型范围而飘移。第二,动态截断是 HOTP 规范里的固定套路,不是随便取的 4 字节,它保证了就算 HMAC 的某些位被翻转,取到的偏移也不会跑出哈希边界。

2.2 密钥生成、Base32 与扫码绑定

TOTP 的种子密钥必须是服务端为每个用户独立生成的,不能用固定的字符串。我用random_bytes(20)生成 160 位随机数,这个强度已经超过大多数需要。

生成的字节没法直接给用户看,Google Authenticator 的扫码解析要求密钥必须 Base32 编码。Base32 编码算法不复杂,只是把任意字节流按 5 位一组重新映射到 A-Z 2-7 这 32 个字符上。我提供一个可直接复用的编码函数:

function base32_encode(string $data): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $binary = ''; foreach (str_split($data) as $char) { $binary .= sprintf('%08b', ord($char)); } $result = ''; foreach (str_split($binary, 5) as $chunk) { $result .= $alphabet[bindec(str_pad($chunk, 5, '0'))]; } return $result; }

同样的,校验的时候需要 Base32 解码,把用户扫码得到的字符串转回原始密钥字节,我这里给出的解码函数要跟编码函数配对使用:

function base32_decode(string $data): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $map = []; for ($i = 0; $i < 32; $i++) { $map[$alphabet[$i]] = $i; } $data = strtoupper(rtrim($data, '=')); $binary = ''; foreach (str_split($data) as $char) { $binary .= str_pad(decbin($map[$char] ?? 0), 5, '0', STR_PAD_LEFT); } $result = ''; foreach (str_split($binary, 8) as $byte) { if (strlen($byte) === 8) { $result .= chr(bindec($byte)); } } return $result; }

绑定阶段的核心是生成 otpauth URI。这个 URI 是一串标准的配置文本,认证器 App 看到它就知道往哪个 App 生成二维码:

$secretBytes = random_bytes(20); $secret = base32_encode($secretBytes); $issuer = 'MyApp'; $account = 'user@example.com'; $uri = sprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d', rawurlencode($issuer), rawurlencode($account), $secret, rawurlencode($issuer), 6, 30, ); // 然后用 endroid/qr-code 把这个 $uri 生成二维码渲染到前端

配套composer require endroid/qr-code,把$uri传给二维码库输出 base64 图片。这里有一个非常容易忽略的步骤:绑定只是把密钥存进数据库还不够,一定要让用户立刻提交一次当前验证码,服务端验证通过才算绑定完成。这个“绑定即验证”的设计,可以防止二维码在传递过程中被劫持,或者用户扫错码绑定成别人的种子。

2.3 校验逻辑:容错、防重放与防时序攻击

校验的核心代码是verify,它做的事情很简单:算出当前时间窗口的验证码,和用户提交的比对,如果不一致就往前、往后各滑动一个窗口再比对。为什么要有滑动窗口?因为用户扫码、打开认证器、手动输入这几个动作会消耗时间,等提交到服务端时,30 秒的时间窗口可能已经翻篇了。

public function verify(string $code, float $timestamp): bool { if (!preg_match('/^\d{' . $this->digits . '}$/', $code)) { return false; } $current = intdiv((int) floor($timestamp), self::STEP); for ($i = -$this->window; $i <= $this->window; $i++) { $candidate = $this->codeAt(($current + $i) * self::STEP); if (hash_equals($candidate, $code)) { return true; } } return false; }

hash_equals不是可选项,不能用===替代。原因很简单:===比较字符串时只要发现第一个不同字符就立刻返回 false,攻击者可以通过测量响应时间反推验证码的字符;hash_equals会固定比较完所有字节,时序上不给攻击者留缝隙。

校验通过之后还有一道防线:防重放。同一个验证码在同一个时间窗口内只能使用一次,否则只是一个被动截获的验证码也能反复登录。实现方式是验证成功后,把当前时间窗口记为“已使用”写入 Redis:

$replayKey = "mfa:totp:replay:{$userId}:{$current}"; $ok = $redis->set($replayKey, '1', ['NX', 'EX' => self::STEP * ($this->window + 1)]); if (!$ok) { throw new RuntimeException('该验证码已使用,请刷新后重试'); }

set命令带上NX参数,表示只有 key 不存在时才写入。第二次使用相同窗口的验证码,set会返回 false,说明被重放了。EX的过期时间设为窗口前后可接受的范围,避免 Redis key 堆积。

2.4 短信邮件验证码与备份码的兜底方案

TOTP 虽然稳,但用户一旦换手机就会遇到“新手机还没绑定、旧手机不在身边”的尴尬。所以必须提供兜底方案。

第一种兜底是短信或邮件验证码。它的实现比 TOTP 简单很多:生成一个 6 位随机数字,存进 Redis 并设置 5 分钟过期,然后调用短信或邮件服务商接口发送。这里有两个关键细节:发送限流和一次性使用。

$code = (string) random_int(100000, 999999); // 60 秒内同一账号只能发一次 $limitKey = "mfa:sms:limit:{$userId}"; if (!$redis->set($limitKey, '1', ['NX', 'EX' => 60])) { throw new RuntimeException('发送太频繁,请稍后再试'); } $redis->setex("mfa:sms:code:{$userId}", 300, password_hash($code, PASSWORD_BCRYPT)); // $smsProvider->send($user->phone, $code);

为什么要对同一个账号限制发送频率?因为短信通道要花钱,攻击者可以利用接口无限轰炸,既浪费成本又可能把运营商的通道封掉。为什么要用password_hash存验证码而不是明文?因为 Redis 里可能有过期数据被 dump 的风险,任何时候都不要把明文验证码留在存储层。

第二种兜底是备份码。备份码的生成逻辑是:生成 10 到 20 个随机码,展示给用户一次,数据库里只存每个备份码的哈希。为什么不能存明文?备份码的杀伤力和密码等同,数据库一旦泄露,明文备份码就直接白给。验证的时候把用户输入的备份码哈希之后比对,匹配成功就从库里删除这条记录,保证只能用一次。

3. 风控拦截:采集信号、计算风险、分级处置

风控引擎的定位是“认证之前的一道闸门”。它不关心用户密码对不对,它只判断这次请求本身是否可疑。我从三个层面来拆:信号怎么采集、分数怎么算、触发之后怎么处置。

3.1 信号采集:设备指纹、IP 情报与行为数据

设备指纹是风控最基础的数据源。前端页面加载时采集浏览器信息,包括 User-Agent、语言、时区、屏幕分辨率、canvas 渲染结果、已安装字体等,然后用 SHA-256 把这些信息拼起来做一次哈希,得到一个设备指纹。代码示意如下:

// 前端采集后通过接口上报 $deviceSignals = [ 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', 'lang' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '', 'screen' => '1920x1080', 'timezone' => -480, 'canvas' => 'hash-string-from-canvas', ]; $fingerprint = hash('sha256', json_encode($deviceSignals));

但要注意,这个 fingerprint 是客户端算出来传给服务端的,理论上可以被伪造。所以不能直接把这个哈希当成身份凭证。更严谨的做法是服务端用 HMAC 密钥对它签发一个设备 ID,后续请求都带着这个签名后的设备 ID 关联用户。

IP 侧的情报可以通过 GeoIP 库拿到国家、城市、经纬度。比如 GeoLite2 离线库就可以。为什么不在这里抛具体服务商?因为生产环境可能用云厂商的安全产品,也可能内网部署离线库,只要最终能给一个 0 到 100 的 IP 风险分就行。

行为数据是最容易获得也最诚实的。密码错误次数、验证码错误次数、同一 IP 的登录频率、请求路径异常程度,这些数据通过 Redis 计数指标就能拿到。它们的价值在于反映“趋势”:单个错误可能是手滑,短时间内 20 次错误就是自动化脚本在跑。

3.2 风险评分模型:规则加权而不是玄学

第一版风控不需要上机器学习,规则加权就够了。原因有两条:一是新业务根本没有历史标注数据供模型训练;二是规则引擎可解释、可调试,客户投诉“为什么被拦截”时你能立刻给出原因。

我这里拟定了一套评分规则:

信号评分区间说明
IP 基础风险0-30GeoIP 识别为 IDC、代理则高分
设备新度0-25该账号历史从未出现过此设备指纹
失败次数0-25最近 10 分钟密码失败次数,每次 +5,上限 25
地理位置跳跃0-20上次登录城市与本次距离超过 500 公里
访问轨迹异常0-15UA 异常、请求路径不符合登录流程
黑名单100命中已知黑名单直接拦截

需要声明的是:这个权重表是“基于常见实践的配置范例”,你可以按照业务特征调整,但调的时候要关注每种规则误伤多少正常用户。核心代码就非常直白:

final class RiskEngine { public function evaluate(array $signals): RiskResult { $score = 0; $score += $signals['ip']->score(); $score += $signals['device_new'] ? 25 : 0; $score += min(25, $signals['fail_count'] * 5); $score += $this->geoJumpScore($signals['geo_before'], $signals['geo_now']); if ($signals['ip']->isBlacklisted()) { return RiskResult::blocked('blacklist ip'); } return RiskResult::scored($score); } }

地理位置跳跃的计算用 Haversine 公式就可以。不要看经纬度差大就拍大腿决定,用户很可能坐飞机出差,这个信号要配合“IP 是否 IDC、本次登录是否新设备”一起看。单独一个维度超过 500 公里就加分,是很粗的规则;比较靠谱的写法是:距离大于阈值并且设备是新设备,才给较高的权重。

3.3 三种处置方式:观察、验证、阻断

评分出来后,处置策略分成三档:

  • 0 到 35 分:直接放行,但把这次请求打上低风险标签写日志。
  • 35 到 70 分:进入二次认证流程,要求 TOTP 或短信验证码,通过了再放行。
  • 70 分以上:拒绝登录,提示环境异常或联系客服人工处理。

第二档里有一个值得注意的细节:如果用户本来就要做多因素认证,那风控评分高时不应该“再让他做一次认证”,而是应该在同一个认证流程里提升挑战难度。比如低风险时只要 TOTP,中高风险时强制 TOTP 加备份码确认。这样才能真正形成两重防线,而不是做两遍无用功。

同样重要的是“后台日志先行”。我给风控引擎加了一个影子模式,所有评分结果都记录到日志表,但初期不实际拦截。上线跑两周之后,把误报和漏报的案例抽出来调权重,再逐步开启拦截。风控系统翻车的重灾区不是技术不够,而是规则直接拍死了正常用户还浑然不觉。

3.4 用 Redis 做滑动窗口计数

滑动窗口计数的核心是“统计最近 N 秒内的事件数量”。我举个密码失败次数的例子。初始密码校验失败时执行:

$failKey = "risk:fail:{$userId}:{$ip}"; $count = $redis->incr($failKey); // 注意:只有第一次累加时才设置过期时间 if ($count === 1) { $redis->expire($failKey, 600); }

关键坑就在if ($count === 1)这一句。如果我在每次累加之后都无脑执行expire($failKey, 600),那么持续 10 分钟内每产生一次失败,key 的过期时间都会往后顺延 10 分钟,这个“滑动窗口”就永远滑不到头了。只有当 key 是新建的时候设置过期,才能保证 10 分钟不动之后窗口自动清零。

另一个常用场景是 IP 登录频率限流,每分钟最多 20 次登录尝试:

$loginKey = "risk:login:{$ip}"; $count = $redis->incr($loginKey); if ($count === 1) { $redis->expire($loginKey, 60); } if ($count > 20) { throw new RuntimeException('尝试过于频繁,请稍后再试'); }

这种计数方式很简单,但也有并发问题:两个请求同时incr,可能出现一个进程看到$count === 1而另一个看到$count === 2的竞态。好在 Redis 的INCR是原子操作,设置过期时以“第一个拿到 1 的进程”为准,基本够用。如果想更严谨,可以用 Lua 脚本把“累加、设置过期、判断阈值”打包成一个原子操作执行。

4. 会话一致性:登录态在多机环境不漂移

会话一致性是这一套系统能不能在生产环境扛住的关键。你把认证和风控都做好了,结果用户一刷新就掉线,体验同样废掉。这一章解决的核心问题是:怎么让所有请求都读到同一个登录态,以及登录态什么时候失效。

4.1 为什么不能用默认 Session

PHP 默认的 Session 是把数据序列化后写到本地文件里的。单机开发没问题,一旦上了负载均衡,请求被转发到另一台机器,那边读不到你的 Session 文件,用户就被判定为未登录。表现就是用户明明才登录成功,下一页又跳回登录页。

解决办法是把 Session 数据统一存到 Redis。最简单的做法是在php.ini里改配置:

session.save_handler = redis session.save_path = "tcp://127.0.0.1:6379?database=2"

这个办法快,但不够灵活。我更喜欢在代码里实现一个SessionHandlerInterface,这样能统一控制 key 前缀、过期时间,并且方便在强制下线时精准删除指定会话。实现见下一节。

4.2 自定义 Redis 会话处理器

先看代码,这一段是整个会话一致性的地基:

final class RedisSessionHandler implements SessionHandlerInterface { public function __construct( private readonly Redis $redis, private readonly int $ttl = 7200, private readonly string $prefix = 'PHPSESSID:', ) {} public function open(string $path, string $name): bool { return true; } public function close(): bool { return true; } public function read(string $id): string|false { return $this->redis->get($this->prefix . $id) ?: ''; } public function write(string $id, string $data): bool { return (bool) $this->redis->setex($this->prefix . $id, $this->ttl, $data); } public function destroy(string $id): bool { return $this->redis->del($this->prefix . $id) > 0; } public function gc(int $max_lifetime): int { return 0; } }

注册方式也很简单:

$redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->select(2); session_set_save_handler(new RedisSessionHandler($redis, 7200), true); session_start();

这里我把 TTL 设为 7200 秒,也就是 2 小时。但每个会话应该有一个“最后活跃时间”的概念,不能简单地 2 小时一刀切。下一节会讲滑动续期。写入时用setex设置过期时间,Redis 会在到达 TTL 后自动删除 key,这就是绝对超时的基础框架。

4.3 登录成功后的 session 重建与会话数据模型

登录成功那一刻,一定要调用session_regenerate_id(true)。这个方法会生成一个新的 Session ID,并把旧的 Session 内容迁移过来,同时删掉旧 ID。这么做的目的是防 Session 固定攻击——攻击者把固定 Session ID 种给用户,用户登录成功后攻击者就可能复用同一个 ID。重建 ID 之后,攻击者手里的旧 ID 直接失效。

然后是会话数据模型。很多新手会把用户对象整个塞进$_SESSION,这是错的。序列化整个用户对象不仅体积大,还有可能出现类属性被篡改的风险。我的做法是把最小必要数据放进去:

$_SESSION['user_id'] = 101; $_SESSION['auth_level'] = 'mfa_verified'; $_SESSION['risk'] = [ 'score' => 20, 'device_id' => 'f3a9b8...', ]; $_SESSION['login_at'] = time(); $_SESSION['last_seen'] = time();

为什么last_seen要单拎出来?因为滑动续期逻辑要用到它。注意auth_level在这里非常有用:它区分“已经通过 MFA”和“还没通过 MFA”的会话。如果风控评分中途升高,你可以只把auth_level改成pending,让用户在下次操作时重新认证,而不是直接销毁整个会话。

4.4 会话续期、超时与主动踢人

续期要解决两个矛盾:用户希望长时间不重登,安全团队希望空闲会话尽快过期。我的方案是“绝对超时 2 小时 + 空闲超时 15 分钟”。

每次请求进来,判断last_seen距离当前时间是否超过 900 秒:

if (time() - $_SESSION['last_seen'] > 900) { // 有活动,续期:重置 session 的 TTL $_SESSION['last_seen'] = time(); $handler = new RedisSessionHandler($redis, 7200); $handler->write(session_id(), session_encode()); }

这里千万注意:不能每次请求都重置 TTL,否则一个挂着不动的浏览器也会因为 15 分钟内恰好有自动请求而无限续期。续期的触发条件必须是“用户确实在做有效操作”,比如点了菜单、打开了新页面,而不是后台定时器发的心跳。

主动踢人的实现,要靠会话索引。登录成功后,把当前 Session ID 加入该用户的会话集合:

$activeSessionsKey = "session:user:{$userId}"; $redis->sAdd($activeSessionsKey, session_id());

强制下线时,遍历集合,逐个删除 Redis 里的 Session key:

foreach ($redis->sMembers($activeSessionsKey) as $sid) { $redis->del("PHPSESSID:{$sid}"); } $redis->del($activeSessionsKey);

这种设计同时支持“踢所有设备”和“只踢某台设备”。如果业务要求同一账号只能一个会话在线,登录成功时删掉集合里除当前 Session ID 之外的所有 ID 就行。要注意的是,删除 Redis 里的 Session 只是单向的,攻击者手里的 Cookie 并不会立刻消失,但因为 Session 读取不到任何数据,下一次请求就会被判定为未登录,安全性不会受影响。

5. 常见问题与避坑清单

这章内容是我实际跑这套系统时遇到的真问题,一个一个列出来,省得你再踩一遍。

5.1 TOTP 时间漂移与容错窗口

TOTP 完全不依赖网络,但它依赖时间。如果服务器时钟不准,验证永远过不了。生产环境一定要配 NTP 同步,排查时可以用timedatectl status看当前时间偏差。用户设备时间偏差大时,我通过把window参数调到 2 解决,也就是前后各允许 2 个窗口,共 2.5 分钟容错。但这个窗口越大重放风险越高,我实际生产只开 1,超过 1 的让用户重新同步设备时间。

5.2 session.save_path 与多机同步

Redis 存 Session 之后,多机同步最大的坑不是 Redis 本身,而是 PHP 序列化格式。如果一台服务器session.serialize_handler是php,另一台是php_binary,它们写入 Session 数据的格式就不一样,Redis 里的同一份数据可能被另一台机器解析失败。统一在每台机器的 php.ini 里配置:

session.serialize_handler = php session.save_handler = redis

然后再启动服务。注意 Redis key 前缀,我用PHPSESSID:就是为了和默认文件 Session 的 key 命名区分开,也方便排查时直接redis-cli查看。

5.3 风控误杀与规则调优

风控引擎上线头一个月,业务方天天来找我“为什么这个客服账号登录被挡了”。查下来基本都是误杀。误杀最常见的原因有三个:代理 IP 走办公网络、用户出差更换城市、新设备首次登录。我的调优方法是给规则加“影响系数”,比如“设备新度”这一条,只对密码风险等级较高的账号加成;对普通用户,即使新设备也最多加 10 分而不是 25 分。早期宁可漏一点,也不要一开始就大杀四方。

5.4 PHP 环境相关的几个真实报错

装 PHP 8.3 时如果编译 zip 扩展,容易碰到no package 'libzip' found,这通常不是 PHP 的问题,而是系统缺少 libzip 开发包。CentOS 上执行yum install libzip-devel,Debian 上执行apt install libzip-dev,再重新编译就能过。Windows 下则是另一个画风,启动时报vcruntime140.dll 14.0 is not compatible with PHP 8.3,原因是 VC++ 运行库版本太老,去微软官网装 VC++ 2015-2022 Redistributable 就能解决。这类环境问题看似和认证逻辑无关,但排查起来能浪费一整天,提前写在清单里免得大家走弯路。

5.5 Cookie 和 HttpOnly 等安全属性

Session 就算放进了 Redis,如果 Cookie 能被 JS 偷偷读走,登录态照样不安全。我在项目里固定设置这几项:

ini_set('session.use_strict_mode', '1'); ini_set('session.use_only_cookies', '1'); ini_set('session.cookie_httponly', '1'); ini_set('session.cookie_samesite', 'Lax'); // 线上确认 HTTPS 之后才会开启: ini_set('session.cookie_secure', $isHttps ? '1' : '0');

use_strict_mode的作用是拒绝用户提交的未知名 Session ID,从源头减少固定会话伪造的可能。cookie_httponly则禁止 JavaScript 读取 Cookie,XSS 就算打进来也偷不走登录态。cookie_samesite=Lax能在跨站请求时把 Cookie 挡在门外,对 CSRF 有不错的抑制效果。最后一条要特别小心:开发环境没有 HTTPS 时千万不要把cookie_secure开成 1,否则 Cookie 根本种不进去,你会看到“登录成功然后立刻掉线”的诡异现象。

把这三块从零到一完整实现一遍之后,最值钱的东西不是哪段代码,而是你终于知道一次登录背后到底发生了什么:风控打分、TOTP 校验、会话重建、Redis 里 key 的增删改查,每一步都有明确的存在理由。以后再看任何现成的登录 SDK 或者框架内置的 auth 模块,你不会再有一层黑盒恐惧,反而能一眼判断出它设计的优劣。

最后再分享一个小技巧:这套系统的调试阶段,一定要在 Redis 里给 session、风控计数、MFA 状态分别指定不同的 database 或 key 前缀,比如PHPSESSID:、risk:、mfa:。这样线上出了故障,你能直接redis-cli看一个 key 的 TTL 和内容,不需要在 PHP 代码里打断点翻哪台机器的日志。我自己这回就是这么救回一次线上大事故的。

返回列表