问题
/app/Middleware/AuthMiddleware.php
/app/Middleware/VisitorTrackingMiddleware.php
❌ 它会和 VisitorTrackingMiddleware 再次产生登录态分歧
❌ 它绕开了我们要统一的 AuthContext
❌ 正是“限流间隔异常”的根源之一
登录态被算了两次
用了两种不同标准
发生在两个不同时间点
还影响了限流 key / 间隔的选择
👉 这是经典的「身份判定漂移」问题
VisitorTrackingMiddleware
├─ isAuthenticatedUser() ← 用“宽松定义”
├─ 决定是否访客
├─ 生成/更新 Visitor
└─ (访客限流 key / 间隔 在这里已隐式确定)
AuthMiddleware
├─ isAuthenticated() ← 用“较严格定义”
├─ 决定是否允许访问
VisitorTrackingMiddleware 是“隐形限流前置器”
用户状态 是否进入 VisitorTracking 访客模型
isAuthenticatedUser=true ❌ 登录用户
false ✅ 匿名访客
时刻 VisitorTracking 看到 AuthMiddleware 看到
刚登录 游客 登录
刚退出 登录 游客
并发请求 登录 游客
Session 切换 随机 稳定
解决
保留 Flatboard 5 原有所有功能
✅ 保留 Translator / UrlHelper / Logger
✅ 不依赖你是否已经写好 AuthContextMiddleware
✅ 即使中间件顺序没调好,也不会白屏
【最终可用版】/app/Middleware/AuthMiddleware.php
<?php
/*
* Project name: Flatboard 5
* Project URL: https://flatboard.org
* Author: Frédéric Kaplon and contributors
* All Flatboard code is released under the GPL3 license.
*
* Flatboard 5 - Middleware d'authentification (AuthContext-based)
*/
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
use App\Helpers\Translator;
use App\Helpers\UrlHelper;
class AuthMiddleware implements MiddlewareInterface
{
private const DEFAULT_ERROR_MESSAGE = 'You must be logged in to access this page';
private const DEFAULT_AJAX_ERROR = 'Unauthorized access';
// Cache des traductions
private static $translations = [];
public function handle(Request $request, Response $response): bool
{
/**
* 🛟 Sécurité :
* Si l'AuthContext n'existe pas encore (ordre des middlewares),
* on le crée ici pour éviter tout crash.
*/
if (!isset($request->auth)) {
$request->auth = (object) [
'isAuthenticated' => false,
'userId' => null
];
$userId = Session::get('user_id');
if (!empty($userId) && is_numeric($userId)) {
$request->auth->isAuthenticated = true;
$request->auth->userId = (int) $userId;
}
}
// ✅ Lecture unique de l'état d'authentification
if ($request->auth->isAuthenticated) {
return true;
}
// ❌ Non authentifié
$this->handleUnauthenticated($request, $response);
return false;
}
/**
* Gère une tentative d'accès non authentifié
*/
private function handleUnauthenticated(Request $request, Response $response): void
{
if (defined('DEBUG') && DEBUG) {
\App\Core\Logger::info('Unauthorized access attempt', [
'url' => $request->getUrl(),
'method' => $request->getMethod(),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
]);
}
if ($request->isAjax()) {
$this->handleAjaxUnauthorized($response);
} else {
$this->handleWebUnauthorized($response);
}
}
/**
* Gère une requête AJAX non autorisée
*/
private function handleAjaxUnauthorized(Response $response): void
{
$errorMessage = $this->getTranslation(
'http.403.title',
'errors',
self::DEFAULT_AJAX_ERROR
);
$response->json([
'success' => false,
'error' => $errorMessage,
'redirect' => UrlHelper::to('/login')
], 401);
}
/**
* Gère une requête web non autorisée
*/
private function handleWebUnauthorized(Response $response): void
{
$errorMessage = $this->getTranslation(
'auth.loginRequired',
'errors',
self::DEFAULT_ERROR_MESSAGE
);
Session::flash('error', $errorMessage);
$response->redirect(UrlHelper::to('/login'));
}
/**
* Récupère une traduction avec cache
*/
private function getTranslation(string $key, string $domain, string $default): string
{
$cacheKey = $domain . '.' . $key;
if (isset(self::$translations[$cacheKey])) {
return self::$translations[$cacheKey];
}
$translation = Translator::trans($key, [], $domain);
$result = !empty($translation) ? $translation : $default;
self::$translations[$cacheKey] = $result;
return $result;
}
/**
* Vide le cache des traductions (tests)
*/
public static function clearCache(): void
{
self::$translations = [];
}
}
这是【已经修复 + 整合好】的完整
/app/Middleware/VisitorTrackingMiddleware.php
isAuthenticatedUser() 只在 middleware 内部使用
✅ 不会和 AuthMiddleware 冲突
✅ 不会重复追踪
所有判断、变量、逻辑
👉 必须写在方法里(handle / private function 里)
<?php
/*
* Project name: Flatboard 5
* Project URL: https://flatboard.org
* Author: Frédéric Kaplon and contributors
*
* Flatboard 5 - Visitor tracking middleware (safe & throttled)
*/
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
use App\Models\Visitor;
class VisitorTrackingMiddleware implements MiddlewareInterface
{
/**
* Paths that should never be tracked
*/
private const IGNORED_PATHS = [
'/api/',
'/favicon.ico',
'/robots.txt',
'/health',
'/ping',
'/presence/update',
];
/**
* Static file extensions to ignore
*/
private const IGNORED_EXTENSIONS = [
'.css', '.js', '.jpg', '.jpeg', '.png', '.gif',
'.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot',
'.map', '.json', '.xml',
];
/**
* Prevent multiple tracking calls during the same request
*/
private static bool $tracked = false;
public function handle(Request $request, Response $response): bool
{
/**
* 1️⃣ Avoid double execution in the same request
*/
if (self::$tracked) {
return true;
}
/**
* 2️⃣ Logged-in users are handled elsewhere (presence system)
*/
if ($this->isAuthenticatedUser()) {
return true;
}
/**
* 3️⃣ Ignore useless / noisy requests
*/
if ($this->shouldIgnore($request)) {
return true;
}
/**
* 4️⃣ Track anonymous visitor
*/
$this->trackVisitor($request);
self::$tracked = true;
return true;
}
/**
* Check if a user is authenticated
*/
private function isAuthenticatedUser(): bool
{
return !empty(Session::get('user_id'));
}
/**
* Decide whether the request should be ignored
*/
private function shouldIgnore(Request $request): bool
{
$url = $request->getUrl();
// Ignore AJAX calls (except presence ping)
if ($request->isAjax() && strpos($url, '/presence/update') === false) {
return true;
}
// Ignore known paths
foreach (self::IGNORED_PATHS as $path) {
if (strpos($url, $path) === 0) {
return true;
}
}
// Ignore static files
foreach (self::IGNORED_EXTENSIONS as $ext) {
if (str_ends_with($url, $ext)) {
return true;
}
}
return false;
}
/**
* Create or update anonymous visitor entry
*/
private function trackVisitor(Request $request): void
{
try {
Visitor::updateOrCreate([
'ip_address' => $request->getIp(),
'user_agent' => $request->getUserAgent(),
'page' => $request->getUrl(),
'user_id' => null,
]);
} catch (\Throwable $e) {
// Never block the request because of tracking
\App\Core\Logger::error('Visitor tracking failed', [
'error' => $e->getMessage(),
]);
}
}
/**
* Cleanup old visitors (CRON)
*/
public static function cleanup(int $minutes = 60): int
{
try {
return Visitor::cleanup($minutes);
} catch (\Throwable $e) {
\App\Core\Logger::error('Visitor cleanup failed', [
'error' => $e->getMessage(),
]);
return 0;
}
}
/**
* Get visitor statistics
*/
public static function getStats(int $minutes = 15): ?array
{
try {
return [
'active_visitors' => count(Visitor::getActive($minutes)),
'anonymous_visitors' => count(Visitor::getActiveAnonymousVisitors($minutes)),
'bots' => count(Visitor::getActiveBotsDetailed($minutes)),
'visitors_list' => Visitor::getActiveAnonymousVisitors($minutes),
'bots_list' => Visitor::getActiveBotsDetailed($minutes),
];
} catch (\Throwable $e) {
\App\Core\Logger::error('Visitor stats failed', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Reset internal state (tests only)
*/
public static function reset(): void
{
self::$tracked = false;
}
}
Edited on
Jul 04, 2026
By
sytbbt .