本文目录导读:

在PHP项目中实现账号锁定策略,主要目的是防止暴力破解密码,基本思路是:记录失败登录次数、时间戳,当达到阈值时锁定账号一段时间。
以下是完整的实现方案,包含数据库设计、核心逻辑代码和RESTful示例。
数据库设计
你需要一张用户表 users 和一张登录尝试记录表,推荐只使用 users 表加字段,减少查询次数。
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
-- 锁定策略字段
failed_attempts INT DEFAULT 0, -- 连续失败次数
locked_until DATETIME DEFAULT NULL, -- 锁定到期时间 (NULL表示未锁定)
last_failed_attempt DATETIME DEFAULT NULL -- 上次失败时间 (用于窗口重置)
);
核心逻辑代码
下面是一个完整的策略类,实现了 滑动窗口 与 渐进锁定 功能。
策略参数(可配置):
$max_attempts= 5 (5次失败触发锁定)$lockout_duration= 1800 (锁定30分钟,单位秒)$reset_window= 600 (连续失败窗口为10分钟,超过10分钟未失败则重置计数)
<?php
// LockoutStrategy.php
namespace App\Security;
class LockoutStrategy {
private $pdo;
private int $maxAttempts = 5;
private int $lockoutDuration = 1800; // 30分钟
private int $resetWindow = 600; // 10分钟窗口
public function __construct(\PDO $pdo) {
$this->pdo = $pdo;
}
// 检查是否被锁定
public function isLocked(string $username): bool {
$sql = "SELECT failed_attempts, locked_until, last_failed_attempt FROM users WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) return false;
// 1. 检查锁定是否已过期
$now = new \DateTime();
if ($row['locked_until'] !== null) {
$lockedUntil = new \DateTime($row['locked_until']);
if ($now < $lockedUntil) {
// 仍在锁定中
return true;
} else {
// 锁定已过期,自动清空锁定状态
$this->resetLockout($username);
return false;
}
}
// 2. 检查失败窗口是否已重置 (距离上次失败超过resetWindow)
if ($row['last_failed_attempt'] !== null) {
$lastFail = new \DateTime($row['last_failed_attempt']);
$interval = $now->getTimestamp() - $lastFail->getTimestamp();
if ($interval > $this->resetWindow) {
// 窗口已过,重置计数
$this->resetAttempts($username);
return false;
}
}
return false;
}
// 记录失败尝试
public function recordFailedAttempt(string $username): void {
$now = (new \DateTime())->format('Y-m-d H:i:s');
// 先检查是否需要重置窗口 (如果距离上次失败太久)
$this->checkWindowReset($username);
// 更新失败次数和上次失败时间
$sql = "UPDATE users
SET failed_attempts = failed_attempts + 1,
last_failed_attempt = :now
WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':now' => $now, ':username' => $username]);
// 检查是否达到阈值,触发锁定
$this->checkAndLock($username);
}
// 记录成功登录(重置所有锁定状态)
public function resetOnSuccess(string $username): void {
$this->resetLockout($username);
}
// 获取剩余锁定时间(秒),用于前端展示
public function getRemainingLockout(string $username): int {
$sql = "SELECT locked_until FROM users WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($row && $row['locked_until'] !== null) {
$lockedUntil = new \DateTime($row['locked_until']);
$now = new \DateTime();
$diff = $now->diff($lockedUntil);
$seconds = ($diff->days * 86400) + ($diff->h * 3600) + ($diff->i * 60) + $diff->s;
return max(0, $seconds);
}
return 0;
}
private function checkWindowReset(string $username): void {
$sql = "SELECT last_failed_attempt FROM users WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($row && $row['last_failed_attempt'] !== null) {
$now = new \DateTime();
$lastFail = new \DateTime($row['last_failed_attempt']);
$interval = $now->getTimestamp() - $lastFail->getTimestamp();
if ($interval > $this->resetWindow) {
$this->resetAttempts($username);
}
}
}
private function checkAndLock(string $username): void {
$sql = "SELECT failed_attempts FROM users WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($row && $row['failed_attempts'] >= $this->maxAttempts) {
$lockedUntil = (new \DateTime())->modify("+{$this->lockoutDuration} seconds")->format('Y-m-d H:i:s');
$update = "UPDATE users SET locked_until = :locked_until WHERE username = :username";
$stmt = $this->pdo->prepare($update);
$stmt->execute([':locked_until' => $lockedUntil, ':username' => $username]);
}
}
private function resetLockout(string $username): void {
$sql = "UPDATE users SET failed_attempts = 0, locked_until = NULL, last_failed_attempt = NULL WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':username' => $username]);
}
private function resetAttempts(string $username): void {
$sql = "UPDATE users SET failed_attempts = 0, last_failed_attempt = NULL WHERE username = :username";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':username' => $username]);
}
}
在登录控制器中使用
<?php
// LoginController.php (伪代码)
require_once 'LockoutStrategy.php';
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$lockout = new \App\Security\LockoutStrategy($pdo);
$username = $_POST['username'];
$password = $_POST['password'];
// 1. 首先检查是否被锁定
if ($lockout->isLocked($username)) {
$remaining = $lockout->getRemainingLockout($username);
http_response_code(429);
echo json_encode([
'error' => '账号已被锁定',
'retry_after_seconds' => $remaining
]);
exit;
}
// 2. 验证密码
$user = getUserByUsername($username);
if ($user && password_verify($password, $user['password_hash'])) {
// 登录成功
$lockout->resetOnSuccess($username);
// 创建session...
echo json_encode(['success' => true]);
} else {
// 登录失败
$lockout->recordFailedAttempt($username);
http_response_code(401);
echo json_encode(['error' => '用户名或密码错误']);
}
进阶优化建议
- IP级锁定(可选):如果目标不是单个用户,而是来自同一个IP的批量攻击,还需要另外建一个表
login_attempts记录IP和时间,实现基于IP的锁定策略。 - 渐进锁定:增加惩罚时长,例如第一次锁定5分钟,第二次15分钟,第三次60分钟,可以在
users表加一个字段lockout_level。 - 防止时序攻击:上述代码在用户不存在时也可以统一返回“用户名或密码错误”,且需要避免不存在用户也能累积重置窗口(可以在不存在时直接返回失败,不记录)。
- 通知机制:触发锁定时,可以发送邮件/短信通知用户 “你的账号因多次失败登录已被锁定30分钟”。
- 使用Redis:对于高并发场景,建议将锁定状态存在 Redis 中(内存操作快),配合 TTL 自动过期,可大幅降低数据库压力。
使用Redis的简化版本(不依赖数据库表字段):
$redis->incr("failed:{$username}");
$redis->expire("failed:{$username}", 600); // 10分钟窗口
if ($redis->get("failed:{$username}") >= 5) {
$redis->setex("locked:{$username}", 1800, 1); // 锁定30分钟
}
这个方案可以让你灵活地适应从轻量级项目到中大型系统的不同需求。