PHP登录系统如何防止暴力破解从密码存储到分层限流建立完整防线
登录接口往往是网站最容易遭受自动化攻击的入口之一。攻击者不需要先攻破服务器,也不需要利用复杂的远程代码执行漏洞,只要不断向/login.php提交用户名和密码,就可能尝试撞开管理员账号、普通用户账号甚至后台管理系统。
对于PHP网站来说,防御登录暴力破解不能依靠某一个函数或者某一条规则。password_hash()只能解决密码存储问题,验证码只能增加自动化攻击成本,IP封禁也只能处理攻击来源的一部分。真正可靠的登录系统应该把密码安全、请求限流、账号保护、会话安全、CSRF防护、异常监控和基础设施层面的防护组合起来。
密码绝对不能以明文或者可逆方式保存
数据库中的密码应该保存密码哈希,而不是明文、MD5、SHA-1或者开发者自己设计的“加密密码”。
PHP提供的password_hash()就是处理密码存储的标准工具:
$hash = password_hash($password, PASSWORD_DEFAULT);
验证时:
if (password_verify($password, $hash)) {
// 密码正确
}
开发者不需要自己生成和保存传统意义上的salt。现代密码哈希函数会在生成哈希时处理随机盐值。
对于新系统,还应该关注PHP当前版本支持的密码算法和参数变化,并在登录成功后使用:
if (password_needs_rehash(
$user['password'],
PASSWORD_DEFAULT
)) {
$newHash = password_hash(
$password,
PASSWORD_DEFAULT
);
// 更新数据库中的密码哈希
}
这样可以让旧用户在正常登录时逐步迁移到新的密码哈希参数,而不需要强制所有用户同时修改密码。
需要强调的是,密码哈希解决的是“数据库泄露之后密码如何保存”的问题,它并不能阻止攻击者向登录接口在线尝试密码。因此密码哈希和登录限流是两个完全不同的安全层。
不要简单地用“5次失败锁账号”
很多教程喜欢设计这样的逻辑:
失败1次
失败2次
失败3次
失败4次
失败5次
账号锁定30分钟
看起来非常安全,但直接照搬到生产系统反而可能产生新的问题。
攻击者只需要知道某个用户名,然后故意连续输入错误密码,就可以让这个账号进入锁定状态。如果锁定时间较长,攻击者甚至不需要知道正确密码,也能持续阻止真实用户登录。
这就是典型的账号锁定型拒绝服务问题。
因此,登录限制应该采用多维度设计,而不是只有一个attempt_count。
可以同时考虑:
IP / IP段
用户名或账号
账号 + IP
设备或会话特征
整个登录接口
例如同一个IP在一分钟内尝试几百个不同账号,应该触发IP侧的限流;某个账号从几十个不同IP同时遭遇大量失败,则应该触发账号侧的风险控制。
这比简单地给用户表增加一个:
attempt_count
更加可靠。
登录限流应该放在应用层之外
如果每一次攻击请求都进入完整的PHP程序,然后PHP再查询MySQL、验证密码、写日志,攻击者实际上已经成功消耗了服务器资源。
因此比较成熟的架构应该尽可能把明显的高频攻击挡在更前面。
例如:
Internet
↓
CDN / WAF / Reverse Proxy
↓
Web Server
↓
PHP-FPM
↓
Login Rate Limiter
↓
Database
在没有专业WAF的情况下,也可以在应用层实现基础限流,但生产环境的大流量站点应该尽量利用Web服务器、反向代理或者边缘安全服务处理高频请求。
如果使用Redis,可以将限流计数放在Redis中,而不是每次失败都直接更新MySQL。
例如可以使用类似:
login:ip:203.0.113.10
login:user:admin@example.com
login:combo:admin@example.com:203.0.113.10
这样的键进行短时间窗口统计。
这样做的意义不仅是性能问题,更重要的是把“安全计数器”和业务用户表分开,避免攻击流量直接把业务数据库变成限流数据库。
不要用sleep()作为主要防暴力破解机制
很多PHP示例喜欢这样写:
if ($attemptCount >= 5) {
sleep(1);
}
从攻击者角度看,这确实增加了单次请求时间,但从服务器角度看,PHP-FPM worker也被占用了。
如果同时出现大量恶意请求,例如几百个并发请求都执行:
sleep(1);
结果可能不是攻击者更难受,而是服务器的PHP worker大量被挂住。
因此延迟可以作为风险控制的一部分,但不能把sleep()当成核心限流方案。
更合理的方式是:
低风险
正常处理
中风险
增加验证要求
高风险
严格限流
极高风险
暂时拒绝请求
真正承担限流工作的应该是计数器、令牌桶、固定窗口、滑动窗口、WAF或者反向代理,而不是大量占用PHP进程等待。
不要通过登录错误信息暴露账号是否存在
下面这种代码虽然方便用户理解:
if (!$user) {
echo '用户名不存在';
} elseif (!password_verify($password, $user['password'])) {
echo '密码错误';
}
但它会产生用户枚举问题。
攻击者可以批量提交用户名,然后根据不同错误信息判断哪些账号存在。
更合理的是统一返回:
用户名或密码错误
服务器内部日志仍然可以准确记录:
user_not_found
password_mismatch
但这些信息不要直接返回给客户端。
响应时间也需要考虑。某些实现中,不存在用户时直接跳过密码哈希验证,而存在用户时执行昂贵的密码验证,攻击者可能通过响应时间推测账号是否存在。
因此高安全场景下,可以对不存在的用户执行一次固定的密码哈希验证,或者采用其他方式尽量减少明显的时间差。
CSRF防护很重要,但它不是暴力破解防护
登录表单通常也应该考虑CSRF。
例如:
if (
empty($_SESSION['csrf_token']) ||
!hash_equals(
$_SESSION['csrf_token'],
$_POST['csrf_token'] ?? ''
)
) {
http_response_code(403);
exit('Invalid request');
}
不过必须理解CSRF和暴力破解解决的是两个不同问题。
CSRF主要防止攻击者诱导用户浏览器替攻击者执行某些操作;暴力破解则是攻击者主动向登录接口大量提交密码。
所以不能因为登录页面有CSRF token,就认为登录接口已经具备暴力破解防护。
同时,限流检查通常应该尽早发生。面对明显的异常高频请求,没有必要让每一个恶意请求都完整执行数据库查询和昂贵的密码验证。
验证码应该作为风险控制,而不是固定流程
验证码确实可以提高自动化攻击成本,但不应该简单设计成:
失败3次
↓
永远显示验证码
这种规则会严重影响正常用户。
更合理的方式是根据风险触发验证。
例如:
正常IP + 正常行为
↓
密码登录
短时间多次失败
↓
增加验证
异常IP + 大量账号
↓
强制验证或限流
明显自动化攻击
↓
直接拒绝
第三方验证服务可以降低自行维护验证码系统的成本,但验证码也不是万能的。现代攻击者可以利用验证码农场、浏览器自动化、代理池以及被控制的真实设备绕过部分验证机制。
所以验证码应该放在整个风险控制体系里,而不是成为唯一防线。
用户账号和IP必须分开限流
这是登录安全设计中非常关键的一点。
只按照IP限制:
一个IP一分钟最多登录20次
攻击者使用代理池以后就可以绕开。
只按照账号限制:
一个账号失败5次就锁定
又容易被拿来攻击正常用户。
因此更合理的是组合策略。
例如:
单IP短时间失败过多
↓
限制该IP
单账号短时间失败过多
↓
提高风险等级
同一IP尝试大量不同账号
↓
明显攻击特征
大量IP同时攻击同一账号
↓
账号进入保护状态
实际阈值应该根据网站用户数量、正常登录频率和业务特点测试,而不是机械照搬“5次”“10次”“15分钟”。
数据库查询必须使用预处理语句
登录系统本身也是SQL注入的攻击目标。
错误写法:
$sql = "SELECT * FROM users
WHERE username = '$username'";
$stmt = $pdo->query($sql);
正确方式:
$stmt = $pdo->prepare(
"SELECT * FROM users WHERE username = ?"
);
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
这不仅适用于登录系统。
用户名、邮箱、手机号、重置密码token等所有来自HTTP请求的数据,都不应该直接拼接进SQL。
同时,数据库账号应该遵循最小权限原则。一个网站登录程序没有理由使用拥有整个数据库删除、创建用户、修改权限能力的超级账号。
登录成功之后必须重新生成Session ID
即使用户名和密码正确,登录过程也还没有结束。
如果攻击者能够提前让受害者使用一个已知Session ID,就可能产生Session Fixation问题。
登录成功后应该重新生成Session ID:
session_regenerate_id(true);
然后再写入用户身份信息:
$_SESSION['user_id'] = $user['id'];
$_SESSION['logged_in'] = true;
同时应该合理设置Cookie属性,例如:
Secure
HttpOnly
SameSite
其中Secure保证Cookie只通过HTTPS发送,HttpOnly降低JavaScript直接读取Session Cookie的风险,SameSite则可以帮助降低部分跨站请求风险。
HTTPS也不能省。密码保护、Session Cookie保护、CSRF防护都不能替代传输层加密。
登录成功和失败都应该留下安全事件记录
生产系统最好记录登录安全事件,例如:
时间
用户ID或规范化用户名
IP地址
User-Agent
成功/失败
失败原因分类
请求ID
风险等级
但不要记录:
明文密码
Session Cookie
Access Token
完整Authorization头
密码重置Token
这些信息一旦进入日志,日志系统本身就会成为新的泄露源。
日志也不要完全依赖:
file_put_contents();
在高并发环境下,应该根据系统规模考虑集中式日志或者专门的日志库。
更重要的是,记录日志之后还要真正分析它。
例如:
同一IP
10分钟内攻击300个账号
和:
同一个账号
10分钟内来自100个IP
都应该成为异常事件。
密码策略不要迷信“必须大小写加特殊字符”
很多网站仍然要求:
至少8位
必须大写
必须小写
必须数字
必须特殊字符
然后用户被迫创造:
Password@123
这类看似复杂、实际上高度可预测的密码。
现代密码策略更应该重视长度,并阻止明显的弱密码和已经出现在公开泄露数据库中的密码。
对于普通用户账号,允许较长密码或密码短语通常比机械要求四种字符类型更有实际意义。
管理员账号和高权限账号则应该进一步启用多因素认证。
2FA比继续堆密码规则更有价值
对于后台管理员、财务账号、网站超级管理员等高价值账户,仅靠用户名和密码已经不够。
可以增加TOTP等多因素认证机制,并为恢复流程设置同等强度的安全控制。
这里有一个经常被忽略的问题:2FA本身并不会自动解决所有账号接管问题。
如果“忘记密码”流程只需要输入邮箱,然后直接发送一个长期有效的重置链接,那么攻击者可能绕过原来的登录安全机制,直接攻击密码恢复流程。
所以真正应该保护的是整个身份认证生命周期:
注册
登录
修改密码
忘记密码
重置密码
修改邮箱
绑定2FA
解除2FA
管理员修改用户密码
任何一个环节过于薄弱,都可能成为绕过登录保护的入口。
Token认证不是暴力破解的替代品
原文把Token-based Authentication列为高级防护措施,这个说法需要区分场景。
Token主要解决的是API身份认证和会话凭证传递问题,并不会天然阻止暴力破解。
如果攻击者不断尝试用户名和密码获得Token,那么登录接口仍然需要限流。
如果攻击者已经盗取有效Token,那么服务器还需要考虑Token生命周期、撤销、权限范围、设备异常以及重放风险。
所以不要因为网站使用JWT或者其他Token,就认为登录系统已经自动安全。
一个生产级PHP登录流程应该是什么样
实际项目可以把登录流程设计成下面这样:
HTTPS请求
↓
基础请求检查
↓
IP / 账号 / 接口限流
↓
CSRF检查
↓
读取用户
↓
密码哈希验证
↓
风险判断
↓
必要时执行MFA
↓
重新生成Session ID
↓
建立登录Session
↓
更新登录时间
↓
记录安全事件
失败流程则应该保持尽可能统一的外部响应:
$stmt = $pdo->prepare(
"SELECT id, password_hash, status
FROM users
WHERE username = ?
LIMIT 1"
);
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$valid = false;
if ($user) {
$valid = password_verify(
$password,
$user['password_hash']
);
}
if (!$valid) {
// 增加IP和账号侧失败计数
// 记录安全事件
// 返回统一错误信息
exit('用户名或密码错误');
}
if ($user['status'] !== 'active') {
// 根据业务规则处理
}
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['logged_in'] = true;
// 更新最后登录时间
// 记录成功登录事件
真正的限流逻辑不应该塞进这一小段代码里,而应该作为独立组件或者独立服务处理。
PHP网站还应该保护登录接口之外的入口
很多攻击并不是直接暴力破解login.php。
攻击者可能首先攻击:
/api/login
/admin/login.php
/password-reset.php
/send-code.php
/verify-code.php
/register.php
/change-password.php
尤其是发送短信验证码、发送邮件验证码、密码重置邮件这类接口,本身就可能成为资源消耗和账号接管的攻击入口。
例如“发送验证码”接口如果没有限制,一个攻击者就可以不断提交同一个邮箱,让系统持续发送邮件。
所以限流应该覆盖整个身份认证体系,而不是只保护用户名密码登录接口。
最后要考虑的是分层,而不是某一个万能开关
一个可靠的PHP登录系统,不应该建立在“验证码”“IP封禁”或者“5次锁定”中的任何一个措施上。
比较合理的安全架构应该至少包括密码哈希、HTTPS、SQL预处理、CSRF防护、Session固定攻击防护、IP和账号组合限流、异常行为检测、统一错误信息、安全日志以及高权限账户的多因素认证。
服务器规模较大时,再把WAF、反向代理、Redis限流、集中式日志和安全监控加入进来。
更重要的是,登录安全需要持续测试。可以通过自动化测试验证正常用户不会因为网络抖动或者密码输错几次就被永久锁死,同时验证攻击者无法通过更换IP、批量用户名、并发请求或者密码重置流程轻易绕过限制。
登录系统的安全性从来不是某个PHP函数带来的。password_hash()解决的是密码怎么保存,预处理语句解决的是SQL注入,CSRF Token解决的是跨站请求,Session重新生成解决的是会话固定,限流解决的是自动化尝试,MFA解决的是密码泄露后的第二道身份验证。
把这些边界分清楚,再把它们组合起来,才能形成真正适合生产环境的认证体系。对于PHP网站来说,最值得投入精力的并不是写一个复杂的“超级登录函数”,而是让每一道安全措施各自解决自己擅长的问题,并且在其中任何一道失效时,后面的防线仍然能够继续工作。
PHP 登录系统如何防止暴力尝试?从基本安全措施开始建立防护
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP