v1.0.51: 批次1架构筑基 - 1)版本迁移(common/migrate.php+schema_versions+natsort迁移文件);2)登录安全(bcrypt平滑迁移login/user_add/user_update旧sha1命中自动重写+system_login_attempts限流+session加固httponly/samesite/secure);3)CSRF Token(服务端checkCsrf+auth/csrf.php登录页取token+前端common.js/login.js统一携带X-CSRF-Token);4)统一基座common/Api.php并存量强制迁移全部70个endpoint(Api::boot按public/super/module/permissions分流,写接口强制checkAjax+checkCsrf,全局异常处理);5)前端收敛(common.js新增esc转义别名);6)REV-4硬数据来源渠道可编辑(hard_update+补全弹窗下拉);7)prepared卫生(soft_update.php修复+analysis.php表名白名单REV-9)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* 统一接口基座 v1(批次1「架构筑基」REV-ARCH-2)
|
||||
*
|
||||
* 所有业务接口统一走 Api::boot(),收敛「require 一串公共文件 + checkAjax +
|
||||
* checkPermission + DB 连接」的重复样板,并提供统一的异常→JSON 兜底。
|
||||
*
|
||||
* 用法(endpoint 顶部):
|
||||
* require_once __DIR__ . '/../common/Api.php';
|
||||
* $pdo = Api::boot(['module' => 'preliminary']); // 登录 + 模块权限(写接口另含 Ajax/CSRF)
|
||||
* $pdo = Api::boot(); // 仅要求登录(如 session.php)
|
||||
* $pdo = Api::boot(['super' => true]); // 仅超级管理员
|
||||
* $pdo = Api::boot(['permissions' => [...]]); // 任一权限
|
||||
* $pdo = Api::boot(['public' => true]); // 免登录(仅 login/forgot/csrf)
|
||||
*
|
||||
* boot() 统一执行:
|
||||
* - 安全 Session(httponly/samesite)
|
||||
* - POST/PUT/PATCH/DELETE:checkAjax(X-Requested-With)+ checkCsrf(Token 头)
|
||||
* - 登录态 + 权限
|
||||
* - 返回 PDO(后续代码可直接用 $pdo;原 `$pdo = DB::getInstance()->getPdo();`
|
||||
* 属冗余无害,可保留)
|
||||
*
|
||||
* 本文件另注册全局异常处理器:未捕获异常统一转 JSON 500 + 技术日志。
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/response.php';
|
||||
require_once __DIR__ . '/logger.php';
|
||||
require_once __DIR__ . '/security.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/validate.php';
|
||||
require_once __DIR__ . '/duplicate_check.php';
|
||||
require_once __DIR__ . '/completeness.php';
|
||||
|
||||
/** 统一异常处理:未捕获异常 → JSON 500 + 技术日志(REV-ARCH-3) */
|
||||
set_exception_handler(function (\Throwable $e) {
|
||||
logError('未捕获异常', [
|
||||
'msg' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? '',
|
||||
]);
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
echo json_encode(['code' => 500, 'msg' => '服务器内部错误,请稍后重试'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
});
|
||||
|
||||
class Api
|
||||
{
|
||||
/**
|
||||
* 统一引导
|
||||
* @param array $opts 见文件头注释
|
||||
* @return PDO
|
||||
*/
|
||||
public static function boot(array $opts = [])
|
||||
{
|
||||
startSessionSecure();
|
||||
|
||||
$isWrite = in_array($_SERVER['REQUEST_METHOD'] ?? 'GET', ['POST', 'PUT', 'PATCH', 'DELETE'], true);
|
||||
if ($isWrite) {
|
||||
checkAjax();
|
||||
}
|
||||
|
||||
if (empty($opts['public'])) {
|
||||
if (!empty($opts['super'])) {
|
||||
requireSuperAdmin();
|
||||
} elseif (!empty($opts['module'])) {
|
||||
checkPermission($opts['module']);
|
||||
} elseif (!empty($opts['permissions'])) {
|
||||
checkAnyPermission($opts['permissions']);
|
||||
} else {
|
||||
requireLogin();
|
||||
}
|
||||
}
|
||||
|
||||
if ($isWrite) {
|
||||
checkCsrf();
|
||||
}
|
||||
|
||||
return DB::getInstance()->getPdo();
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -6,13 +6,15 @@
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/response.php';
|
||||
require_once __DIR__ . '/security.php';
|
||||
|
||||
/** 启动 Session */
|
||||
/**
|
||||
* 启动 Session(批次1 起经 security.php 加固:httponly + samesite=Lax + HTTPS secure)
|
||||
* 兼容既有调用方式,行为不变,仅 cookie 属性增强(REV-SEC-4/REV-8)
|
||||
*/
|
||||
function startSession()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
startSessionSecure();
|
||||
}
|
||||
|
||||
/** 校验写类接口的 Ajax 头 */
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
* 返回各字典表启用中的数据,供前端下拉/联动使用。
|
||||
* 数据来源:官方 Excel 生成的种子数据(见 sql/dict_seed.sql),不依赖任何第三方接口。
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/response.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/Api.php';
|
||||
$pdo = Api::boot();
|
||||
|
||||
requireLogin();
|
||||
|
||||
$type = trim($_REQUEST['type'] ?? 'all');
|
||||
$pdo = DB::getInstance()->getPdo();
|
||||
|
||||
@@ -55,3 +55,21 @@ function logCurrent($action, $module, $targetTable = null, $targetId = null, $co
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 技术异常日志(批次1 REV-ARCH-3):写入 runtime/logs/error-YYYYMM.log
|
||||
* 业务审计走 system_logs(logAction/logCurrent),技术异常走此文件。
|
||||
* @param string $message
|
||||
* @param mixed $context 上下文数组,自动 JSON 编码
|
||||
*/
|
||||
function logError($message, $context = null)
|
||||
{
|
||||
$dir = __DIR__ . '/../../runtime/logs';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message
|
||||
. ($context !== null ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '')
|
||||
. PHP_EOL;
|
||||
@file_put_contents($dir . '/error-' . date('Ym') . '.log', $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,13 @@
|
||||
* 返回:{ country, province, city, label, found }
|
||||
* label 示例:"中国-广东省-深圳市" / "中国香港" / "美国"
|
||||
*/
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/response.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/Api.php';
|
||||
$pdo = Api::boot();
|
||||
|
||||
/**
|
||||
* 接口入口:仅当作为 HTTP 接口直接访问时执行(便于函数被 require 复用/测试)
|
||||
*/
|
||||
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
|
||||
requireLogin();
|
||||
|
||||
$phone = trim($_REQUEST['phone'] ?? '');
|
||||
if ($phone === '') {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user