c86f08c325
Co-Authored-By: Claude Code <noreply@anthropic.com>
85 lines
2.9 KiB
PHP
85 lines
2.9 KiB
PHP
<?php
|
||
/**
|
||
* 安全中间件(批次1):Session 加固 + CSRF Token + 登录限流
|
||
* 依赖:db.php / response.php / logger.php(均无回环依赖)
|
||
*/
|
||
require_once __DIR__ . '/db.php';
|
||
require_once __DIR__ . '/response.php';
|
||
require_once __DIR__ . '/logger.php';
|
||
|
||
/** 安全启动 Session:httponly + samesite=Lax + HTTPS 下 secure */
|
||
function startSessionSecure()
|
||
{
|
||
if (session_status() === PHP_SESSION_NONE) {
|
||
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||
session_set_cookie_params([
|
||
'lifetime' => 0,
|
||
'path' => '/',
|
||
'domain' => '',
|
||
'secure' => $secure,
|
||
'httponly' => true,
|
||
'samesite' => 'Lax',
|
||
]);
|
||
session_start();
|
||
}
|
||
}
|
||
|
||
/** 获取(或创建)当前会话绑定的 CSRF Token */
|
||
function csrfToken()
|
||
{
|
||
startSessionSecure();
|
||
if (empty($_SESSION['csrf_token']) || strlen($_SESSION['csrf_token']) !== 64) {
|
||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||
}
|
||
return $_SESSION['csrf_token'];
|
||
}
|
||
|
||
/**
|
||
* 校验写接口的 CSRF Token(要求 X-CSRF-Token 请求头与会话一致)
|
||
* 配合前端 common.js httpPost 统一附带;防跨站请求伪造(REV-2/REV-SEC-2)
|
||
*/
|
||
function checkCsrf()
|
||
{
|
||
startSessionSecure();
|
||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||
if ($token === '' || empty($_SESSION['csrf_token'])
|
||
|| !hash_equals($_SESSION['csrf_token'], $token)) {
|
||
Response::error('安全校验失败(CSRF),请刷新页面后重试', 403);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 登录限流:同一 IP+账号 15 分钟内失败 ≥5 次则锁定(429)
|
||
* fail-open:DB 异常时跳过限流,保证登录可用(REV-8)
|
||
*/
|
||
function checkLoginThrottle($username, $ip)
|
||
{
|
||
try {
|
||
$pdo = DB::getInstance()->getPdo();
|
||
$pdo->prepare("DELETE FROM system_login_attempts WHERE attempt_time < NOW() - INTERVAL 30 MINUTE")->execute();
|
||
$stmt = $pdo->prepare(
|
||
"SELECT COUNT(*) FROM system_login_attempts
|
||
WHERE ip = ? AND username = ? AND success = 0 AND attempt_time > NOW() - INTERVAL 15 MINUTE"
|
||
);
|
||
$stmt->execute([$ip, $username]);
|
||
if ((int)$stmt->fetchColumn() >= 5) {
|
||
Response::error('尝试次数过多,请15分钟后再试', 429);
|
||
}
|
||
} catch (Exception $e) {
|
||
logError('登录限流检查失败(已跳过限流)', ['msg' => $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
/** 记录一次登录尝试结果(供限流统计) */
|
||
function recordLoginAttempt($username, $ip, $success)
|
||
{
|
||
try {
|
||
$pdo = DB::getInstance()->getPdo();
|
||
$pdo->prepare(
|
||
"INSERT INTO system_login_attempts (username, ip, success, attempt_time) VALUES (?, ?, ?, NOW())"
|
||
)->execute([$username, $ip, $success ? 1 : 0]);
|
||
} catch (Exception $e) {
|
||
logError('登录尝试记录失败', ['msg' => $e->getMessage()]);
|
||
}
|
||
}
|