From b3c8188ee31b5469ee2c5ca142a6bedf6148cfa2 Mon Sep 17 00:00:00 2001 From: Egor Isaev Date: Tue, 23 Jun 2026 16:30:58 +0300 Subject: [PATCH] dev --- App/Controller/FeedbackController.php | 52 ++++++++++++++++++++ App/view/Feedback/feedback.html | 37 ++++++++++++++ App/view/Feedback/feedback_ok.html | 13 +++++ CLAUDE.md | 53 ++++++++++++++++++-- System/Classes/BaseController.php | 46 ++++++++++++++++++ System/Classes/CSRF.php | 69 +++++++++++++++++++++++++++ System/Classes/Controller.php | 54 +++++++++++++++++++-- System/Classes/Request.php | 2 +- 8 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 App/Controller/FeedbackController.php create mode 100644 App/view/Feedback/feedback.html create mode 100644 App/view/Feedback/feedback_ok.html create mode 100644 System/Classes/BaseController.php create mode 100644 System/Classes/CSRF.php diff --git a/App/Controller/FeedbackController.php b/App/Controller/FeedbackController.php new file mode 100644 index 0000000..1527f41 --- /dev/null +++ b/App/Controller/FeedbackController.php @@ -0,0 +1,52 @@ +method() === HTTPRequest::POST) { + // CSRF уже проверен в BaseController::before(). Здесь — только данные. + $validation = Validation::factory($request->post()) + ->label('email', 'E-mail') + ->label('message', 'Сообщение') + ->rule('email', 'required') + ->rule('email', 'email') + ->rule('message', 'required') + ->rule('message', 'min_length', [10]); + + if ($validation->check()) { + // Данные чистые — здесь было бы сохранение/отправка письма. + return $this->render('feedback_ok', ['email' => $request->post('email')]); + } + + $errors = $validation->errors(); + } + + return $this->render('feedback', ['errors' => $errors]); + } +} diff --git a/App/view/Feedback/feedback.html b/App/view/Feedback/feedback.html new file mode 100644 index 0000000..42ae34b --- /dev/null +++ b/App/view/Feedback/feedback.html @@ -0,0 +1,37 @@ + сообщение] + */ + +use System\Classes\CSRF; + +?> + +
+ + +

+ + + + +

+ +

+ + + + +

+ + +
diff --git a/App/view/Feedback/feedback_ok.html b/App/view/Feedback/feedback_ok.html new file mode 100644 index 0000000..fa0c75f --- /dev/null +++ b/App/view/Feedback/feedback_ok.html @@ -0,0 +1,13 @@ + + +

Спасибо! Сообщение от принято.

diff --git a/CLAUDE.md b/CLAUDE.md index 17ed60c..42cd871 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,8 @@ PhpStorm может показывать предупреждение «Namespac | Класс | Роль | |---|---| | `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды | -| `System\Classes\Controller` | Базовый контроллер; `render()` строит двухуровневый вывод: layout + content | +| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` | +| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()` + авто-CSRF в `before()` + `json()` | | `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` | | `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` | | `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` | @@ -84,6 +85,7 @@ PhpStorm может показывать предупреждение «Namespac | `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` | | `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` | | `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` | +| `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` | | `System\Classes\HTTP` | `redirect()`, `requestHeaders()` | | `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат | | `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | @@ -102,7 +104,7 @@ PhpStorm может показывать предупреждение «Namespac index.php → Core::init() → Request::factory() # detectUri(), Route::resolve(), заполняет post/query/body/method - → Request::execute() # require_once контроллера, вызывает {action}Action() + → Request::execute() # require_once контроллера, вызывает executeAction({action}Action) → Response->body() # строка с HTML → echo ``` @@ -206,6 +208,38 @@ if ($validation->check()) { Правила: `required`, `email`, `url`, `numeric`, `digit`, `min_length`, `max_length`, `exact_length`, `matches`, `in`, `regex`. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет `required`) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами `:field`/`:param1`/`:param2` (подстановка через `strtr`, как в `MyException`). +### CSRF (System\Classes\CSRF) + +Защита форм от CSRF. Токен хранится в сессии (`Session`), встраивается в форму скрытым полем и проверяется при обработке POST. + +**Контроллеры — наследники `Controller` — проверяют токен автоматически** в `before()` на методах `POST/PUT/PATCH/DELETE` (отключается флагом `$_csrf_protection = false`, например для API/вебхуков). В шаблоне достаточно вставить поле: + +```php +use System\Classes\CSRF; + +
+ + ... +
+``` + +Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через `CSRF::validate()`: + +```php +use System\Classes\HTTP\HTTPException; + +if (!CSRF::validate(Request::$current->post(CSRF::$key))) { + throw HTTPException::factory(403); +} +``` + +> Контроллер создаётся в `Request::execute()` как `new $class()` без передачи запроса — доступ к данным через статический `Request::$current`, не через `$this->request`. Рабочий пример формы (валидация поверх авто-CSRF): `App/Controller/FeedbackController.php`. + +- `token()` — токен текущей сессии (создаётся при первом обращении, `random_bytes(32)`). +- `validate($value)` — сравнение с сессией через `hash_equals()` (устойчиво к timing-атакам). +- `field()` — готовый `` с токеном. +- `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`). + ### HTTPException (System\Classes\HTTP\HTTPException) ```php @@ -240,6 +274,17 @@ $response = HTTPException::factory(403)->getResponse(); - `$_paths` инициализируется лениво при первом вызове `findFile()` - `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) +### Controller hierarchy + +Расстановка имён — как в проекте eoffice_v3: + +- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API. +- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. **Контроллеры приложения наследуют его.** + +API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3). + +`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет). + ### Environment Среда задаётся через `SetEnv APP_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT). @@ -271,8 +316,10 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load | | `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes | | `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent | +| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии | +| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки | -**135 тестов, 215 assertion — все проходят.** +**145 тестов, 229 assertion — все проходят.** ### Frontend dependencies (через Composer) diff --git a/System/Classes/BaseController.php b/System/Classes/BaseController.php new file mode 100644 index 0000000..ea368a7 --- /dev/null +++ b/System/Classes/BaseController.php @@ -0,0 +1,46 @@ +before(); + $body = $this->$action(); + $this->after(); + + return $body; + } +} diff --git a/System/Classes/CSRF.php b/System/Classes/CSRF.php new file mode 100644 index 0000000..68826ce --- /dev/null +++ b/System/Classes/CSRF.php @@ -0,0 +1,69 @@ + + * + * ... + * + * + * if (!CSRF::validate($request->post('csrf_token'))) { + * throw HTTPException::factory(403); + * } + */ +class CSRF +{ + /** @var string Имя поля/ключа сессии для токена */ + public static string $key = 'csrf_token'; + + /** + * Возвращает токен текущей сессии, создавая его при первом обращении. + * + * @return string + */ + public static function token(): string + { + $session = Session::instance(); + $token = $session->get(self::$key); + + if (!$token) { + $token = bin2hex(random_bytes(32)); + $session->set(self::$key, $token); + } + + return $token; + } + + /** + * Проверяет присланный токен против токена из сессии. + * + * @param string|null $value Значение из Request::post(CSRF::$key) + * @return bool + */ + public static function validate(?string $value): bool + { + $token = Session::instance()->get(self::$key); + + return is_string($value) && is_string($token) && hash_equals($token, $value); + } + + /** + * Возвращает готовое скрытое поле с токеном для вставки в форму. + * + * @return string + */ + public static function field(): string + { + return ''; + } +} diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php index dda6b36..9fc500e 100644 --- a/System/Classes/Controller.php +++ b/System/Classes/Controller.php @@ -8,14 +8,43 @@ namespace System\Classes; +use System\Classes\HTTP\HTTPException; +use System\Classes\HTTP\Request as HTTPRequest; + /** - * Базовый контроллер. render() строит двухуровневый вывод: layout + content. + * Веб-контроллер: рендеринг layout + content, авто-проверка CSRF на небезопасных + * методах и JSON-ответы. Контроллеры приложения наследуют его. */ -class Controller +class Controller extends BaseController { /** @var string Имя layout-шаблона в System/view/views */ protected string $_layout = 'layout'; + /** @var bool Проверять ли CSRF-токен на небезопасных методах */ + protected bool $_csrf_protection = true; + + /** + * Проверяет CSRF-токен на POST/PUT/PATCH/DELETE. + * Отключается флагом $_csrf_protection (например, для API/вебхуков). + * + * @return void + * @throws HTTPException 403, если токен не прошёл + */ + protected function before(): void + { + if (!$this->_csrf_protection) { + return; + } + + $request = Request::$current; + $method = $request?->method() ?? HTTPRequest::GET; + $unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE]; + + if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) { + throw HTTPException::factory(403); + } + } + /** * Рендерит шаблон контента внутри layout. * Если $dir пуст — определяется автоматически из имени класса @@ -30,12 +59,29 @@ class Controller protected function render(string $template, array $data = [], string $dir = ''): string { if ($dir === '') { - $class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController - $dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo + $class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController + $dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo } return (new View($this->_layout, 'view/views', [ 'content' => (new View($template, $dir, $data))->render() ]))->render(); } + + /** + * Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные. + * + * @param mixed $data Данные ответа + * @param int $status HTTP-статус + * @return string JSON + */ + protected function json(mixed $data, int $status = 200): string + { + http_response_code($status); + if (!headers_sent()) { + header('Content-Type: application/json; charset=utf-8'); + } + + return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } } diff --git a/System/Classes/Request.php b/System/Classes/Request.php index febf0e3..d00c4cb 100644 --- a/System/Classes/Request.php +++ b/System/Classes/Request.php @@ -129,7 +129,7 @@ class Request implements HTTPRequest return HTTPException::factory(404)->getResponse(); } - return (new Response())->body((new $class())->$method()); + return (new Response())->body((new $class())->executeAction($method)); } /**