资讯详情

资讯详情

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 一次登录请求的完整生命周期把流程在脑子里过一遍后面所有代码都是给这个流程服务的用户提交账号密码服务端先校验账号是否存在、密码哈希是否匹配。密码校验通过后立刻进入风控引擎。风控会采集当前设备的指纹、IP 地理位置、User-Agent、历史失败次数等信号。风控引擎输出风险评分。0 到 35 分直接放行35 到 70 分要求二次认证70 分以上直接拒绝。如果需要二次认证就触发 TOTP 或短信验证码流程用户提交一次性验证码。认证通过后调用session_regenerate_id(true)重建会话把用户身份和登录时间写入 Redis并下发 Cookie。用户后续请求会带着 Session IDRedis 里读出来校验是否过期、是否被强制下线然后做滑动续期。这个链路里风控在认证之前认证在会话建立之前。顺序不能反——如果先建会话再做风控风控一旦把用户拦截会话状态就会出现半登录半匿名的尴尬局面。2. 多因素认证从 TOTP 到验证码的完整链路多因素认证这块我推荐优先做 TOTP。它不依赖短信通道用户装一个 Google Authenticator、Microsoft Authenticator 或者 1Password 就能用。TOTP 的完整落地包含四个部分核心算法、密钥绑定、校验逻辑、兜底方案。下面逐个拆。2.1 TOTP 的核心算法HMAC 与时间窗口TOTP 的本质不是加密而是“用时间和密钥计算一个动态短码”。它的底层是 HOTPHMAC-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 userexample.com; $uri sprintf( otpauth://totp/%s:%s?secret%sissuer%sdigits%dperiod%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 为什么不能用默认 SessionPHP 默认的 Session 是把数据序列化后写到本地文件里的。单机开发没问题一旦上了负载均衡请求被转发到另一台机器那边读不到你的 Session 文件用户就被判定为未登录。表现就是用户明明才登录成功下一页又跳回登录页。解决办法是把 Session 数据统一存到 Redis。最简单的做法是在php.ini里改配置session.save_handler redis session.save_path tcp://127.0.0.1:6379?database2这个办法快但不够灵活。我更喜欢在代码里实现一个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 keyforeach ($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-develDebian 上执行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 读取 CookieXSS 就算打进来也偷不走登录态。cookie_samesiteLax能在跨站请求时把 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 代码里打断点翻哪台机器的日志。我自己这回就是这么救回一次线上大事故的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →