.htaccess 或防火牆封禁 IP,持續時間可配置 (如 1 小時/永久)。<?php
/**
* IP 防刷新/防攻擊中介軟體
* 儲存:log/ip_requests.log (JSON Lines), log/banned_ips.txt
* 部署:放入 public/index.php 最頂端,或框架中介軟體最前端
*/
declare(strict_types=1);
class IpGuard
{
private string $logDir;
private int $windowSeconds; // 滑動窗口秒數
private int $maxRequests; // 窗口內最大請求數
private int $banDuration; // 封禁秒數 (0=永久)
private string $banFile;
private string $requestLogFile;
public function __construct(array $config = [])
{
$this->logDir = $config['log_dir'] ?? __DIR__ . '/log';
$this->windowSeconds = $config['window_seconds'] ?? 120;
$this->maxRequests = $config['max_requests'] ?? 120;
$this->banDuration = $config['ban_duration'] ?? 3600;
$this->banFile = $this->logDir . '/banned_ips.txt';
$this->requestLogFile = $this->logDir . '/ip_requests.log';
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0750, true);
}
if (!is_file($this->banFile)) touch($this->banFile);
if (!is_file($this->requestLogFile)) touch($this->requestLogFile);
}
public function handle(): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$now = time();
// 1. 檢查永久/暫時封禁名單
if ($this->isBanned($ip, $now)) {
$this->respondForbidden($ip, $now);
}
// 2. 讀取請求日誌 (最近窗口內)
$requests = $this->loadRecentRequests($ip, $now);
// 3. 判斷是否超限
if (count($requests) >= $this->maxRequests) {
$this->banIp($ip, $now);
$this->respondForbidden($ip, $now);
}
// 4. 記錄本次請求
$this->logRequest($ip, $now);
}
private function isBanned(string $ip, int $now): bool
{
$lines = file($this->banFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
[$bannedIp, $banTime, $duration] = explode('|', $line);
if ($bannedIp === $ip) {
if ($duration == 0 || $now - (int)$banTime < (int)$duration) {
return true;
}
// 過期自動清理 (可選)
}
}
return false;
}
private function loadRecentRequests(string $ip, int $now): array
{
$requests = [];
$lines = file($this->requestLogFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
[$logIp, $logTime] = explode('|', $line);
if ($logIp === $ip && $now - (int)$logTime < $this->windowSeconds) {
$requests[] = (int)$logTime;
}
}
return $requests;
}
private function logRequest(string $ip, int $now): void
{
file_put_contents($this->requestLogFile, "$ip|$now\n", FILE_APPEND | LOCK_EX);
// 簡單清理:每 1000 次請求清理一次過期日誌
if (rand(1, 1000) === 1) {
$this->cleanupLogs($now);
}
}
private function cleanupLogs(int $now): void
{
$cutoff = $now - $this->windowSeconds;
$lines = file($this->requestLogFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$kept = [];
foreach ($lines as $line) {
[$logIp, $logTime] = explode('|', $line);
if ((int)$logTime >= $cutoff) $kept[] = $line;
}
file_put_contents($this->requestLogFile, implode("\n", $kept) . "\n", LOCK_EX);
}
private function banIp(string $ip, int $now): void
{
$duration = $this->banDuration;
file_put_contents($this->banFile, "$ip|$now|$duration\n", FILE_APPEND | LOCK_EX);
error_log("IP 封禁: $ip (持續 $duration 秒)");
}
private function respondForbidden(string $ip, int $now): void
{
http_response_code(403);
header('Content-Type: text/html; charset=UTF-8');
$retryAfter = $this->banDuration ?: 3600;
header("Retry-After: $retryAfter");
exit("<h1>403 Forbidden</h1><p>您的 IP ($ip) 請求過於頻繁,已被暫時封禁。請 $retryAfter 秒後再試。</p>");
}
}
// === 使用方式 ===
// 在 public/index.php 最開頭引入
require_once __DIR__ . '/IpGuard.php';
$guard = new IpGuard([
'window_seconds' => 120,
'max_requests' => 120,
'ban_duration' => 3600,
]);
$guard->handle();
// 後續正常框架啟動...
?>
| 維度 | 建議 |
|---|---|
| 儲存效能 | 改用 Redis Sorted Set (ZADD ip:requests timestamp ip, ZRANGEBYSCORE),毫秒級、原子操作、天然過期。 |
| 分散式一致性 | 多台 PHP-FPM 共享 Redis,避免單機日誌不同步。 |
| 白名單機制 | 內網 IP、搜尋引擎 Bot (User-Agent 判斷)、合作夥伴 API IP 直接放行。 |
| 靜態資源放行 | Nginx 直接服務靜態檔,不經 PHP,在 Nginx 層用 limit_req_zone 限流。 |
| 監控告警 | Grafana 面板顯示當前封禁 IP 數、請求頻率分佈、Top 20 IP。 |
檔案版滑動窗口限流適合**單機、中小流量、快速上線**場景。隨業務增長,**務必遷移至 Redis + Nginx 多層限流架構**,並在應用層實施業務邏輯級防刷 (如表單 Token、驗證碼、風控引擎)。