5. Session і CSRF
Форма на /contact вже валідує поля, але POST без захисту
вразливий до CSRF: сторонній сайт може надіслати запит від імені
користувача з його cookies (браузер сам підставить сесію, якщо вона
вже є — або просто «підписати» дію, коли сесію додамо). Тому зараз
з’являються два шари: сесія (стан між запитами) і CSRF-токен,
який знає лише наш шаблон.
Session — окрема capability; CSRF — opt-in на Session builder
(withCsrf()), а не окремий top-level brick. Middleware
для перевірки токена — у routes/web.php, не в Stack:
поведінка web surface видна явно. API-трек цього кроку не торкається —
йому session не потрібна.
Що додається і навіщо
-
addSession()— Symfony session + file handler уstorage/sessions: місце для токена, flash і (пізніше) old input після redirect -
withCsrf()—CsrfTokenManagerпов’язаний із session; без сесії токену ніде жити між GET і POST -
VerifyCsrfTokenMiddleware— перевіряє_csrf_tokenна POST/PUT/PATCH/DELETE, GET пропускає -
HandleCsrfExceptionMiddleware— flash + redirect back при невалідному токені, замість сирого 500 / Whoops -
StorePreviousUrlMiddleware— щоб redirect back знав URL форми (не лише Referer, який браузер може не надіслати); ключі сесії — уSessionKey -
Приховане поле
_csrf_tokenі вивідflashes.errorуcontact.twig
ApplicationStackBootstrap.php
Ми викликаємо addSession(), щоб stateful web (токен,
flash пізніше) не змішувався з stateless API. Шлях до session files
передаємо явно через setHandlerFactory() і
NativeFileSessionHandler — Stack
не вгадує storage сам. withCsrf() реєструє token
manager; middleware у routes залишається на додатку — Stack не
«вішає CSRF на весь світ» автоматично.
Повний API builder-а — Session brick. Коли варто: HTML-форми, логін, будь-який POST з cookies. Коли ні: чистий JSON API без cookie-сесії — capability можна не додавати (як на кроці 4).
$stack->addValidation();
$stack->addSession()
->setHandlerFactory(function () use ($rootPath) {
return new NativeFileSessionHandler($rootPath . 'storage/sessions');
})
->withCsrf();
$stack->addHttp()
->setRoutes([$rootPath . 'routes/web.php']);
$stack->addView()
->withTwig()
->setViewsPath($rootPath . 'resources/views');
routes/web.php
CSRF middleware на весь web router: GET проходить без перевірки,
POST /contact — з перевіркою. Порядок — це історія
запиту, не випадковий список: previous URL першим (для
redirectBack), потім HandleCsrfException
(ловить), VerifyCsrfToken всередині (кидає при mismatch).
Інакше exception вилетів би «вгору» без flash і без куди вернутись.
$router->lazyMiddlewares([
StorePreviousUrlMiddleware::class,
HandleCsrfExceptionMiddleware::class,
VerifyCsrfTokenMiddleware::class,
]);
$router->get('/contact', [ContactController::class, 'show'])->setName('contact');
$router->post('/contact', [ContactController::class, 'submit'])->setName('contact.submit');
SessionKey.php і StorePreviousUrlMiddleware
Ключі сесії для redirect back не розкидаємо рядками по middleware:
один клас Concept\App\Http\SessionKey — glue додатку,
не контракт extension. На цьому кроці лише URL для
StorePreviousUrlMiddleware; flash-ключі для валідації
додамо на кроці 7.
final class SessionKey
{
public const string VALIDATION_ERRORS = '_validation_errors';
public const string VALIDATION_DATA = '_validation_data';
public const string URL_PREVIOUS = '_url_previous';
public const string URL_CURRENT = '_url_current';
}
Middleware на GET (не AJAX) запам’ятовує поточний URI в
URL_CURRENT, попередній — у URL_PREVIOUS,
і кладе safe back URL в атрибут запиту для
redirectBack() після CSRF mismatch.
$currentInSession = $this->session->get(SessionKey::URL_CURRENT);
if ($uri !== $currentInSession) {
$this->session->set(SessionKey::URL_PREVIOUS, $currentInSession);
$this->session->set(SessionKey::URL_CURRENT, $uri);
}
ContactController.php
Токен і flash передаємо явно з controller: без глобального
view composer. Так видно зв’язок session → view — що саме шаблон
очікує і звідки воно береться. Після CSRF-fail middleware пише
flash; на наступному GET FlashBagInterface::all()
віддає його в шаблон.
private function formViewData(): array
{
return [
'csrf_token' => $this->csrf->getToken(),
'flashes' => $this->flashBag->all(),
'errors' => [],
'old' => [],
'success' => false,
'validated' => null,
];
}
contact.twig
Приховане поле _csrf_token — те саме ім’я, що очікує
VerifyCsrfTokenMiddleware. Без нього навіть валідні
name/email/message не пройдуть: спочатку CSRF, потім валідація.
Блок flashes.error показує повідомлення після redirect
(наприклад CSRF token mismatch) — користувач бачить
форму знову, а не «мертвий» POST після F5.
{% if flashes.error is defined %}
<div class="alert alert-danger" role="alert">
{% for message in flashes.error %}<div>{{ message }}</div>{% endfor %}
</div>
{% endif %}
<form method="post" action="/contact">
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
…
</form>
Чому не ShareViewDataMiddleware зараз
Пізніше з’явиться middleware, який кладе csrf_token,
flashes, errors і old у
RequestAttribute::VIEW_PAYLOAD — їх підхоплює
ViewResponseFactory::create() без дублювання в кожному
controller. На цьому кроці навмисно передаємо token і flash
явно в ContactController, щоб було видно
зв’язок session → view, перш ніж сховати його в middleware.
ShareViewDataMiddleware з’явиться на
кроці 7, коли додамо
HandleValidationExceptionMiddleware і redirect+flash
для валідації: тоді однакові змінні потрібні на кількох сторінках,
і DRY через VIEW_PAYLOAD виправданий. Зараз форма одна —
зайва абстракція лише затуманила б історію.
Запуск і перевірка
Тег step-05-session.
Очікуємо: POST без CSRF-токена → 302 і flash-помилка;
POST з токеном із сесії → 200.
Запуск
git checkout step-05-session
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build
Перевірка в браузері
- Відкрийте http://localhost:8089/contact.
-
Надішліть форму без змін (або видаліть hidden
_csrf_tokenу DevTools) — після redirect має з’явитись flash CSRF token mismatch. - Оновіть сторінку, заповніть форму коректно і надішліть знову — 200 і зелений alert з validated-даними.
Перевірка з CLI
curl -s -c /tmp/cookies.txt http://localhost:8089/contact >/dev/null
curl -s -b /tmp/cookies.txt -o /dev/null -w '%{http_code} redirect\n' -X POST http://localhost:8089/contact \
-d 'name=Anna&email=anna@example.com&message=Hello there friend'
# 302 → GET /contact з flash «CSRF token mismatch»
TOKEN=$(curl -s -b /tmp/cookies.txt http://localhost:8089/contact \
| sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p')
curl -s -b /tmp/cookies.txt -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8089/contact \
-d "name=Anna&email=anna@example.com&message=Hello there friend&_csrf_token=$TOKEN"
# 200
Ланцюжок
Щасливий шлях майже як на кроці 3, але перед validate стоїть CSRF. Невдалий шлях — окрема історія: exception → flash → GET форми.
addSession()->withCsrf() + middleware у web.php
GET /contact → token + contact.twig
POST /contact (без token)
→ VerifyCsrfToken → CsrfException
→ HandleCsrfException → flash + redirectBack(/contact)
GET /contact → flashes.error у шаблоні
POST /contact (з token) → validate → 422/200
Крок 6 — База даних (спільний для web і api після merge): валідний contact нарешті кудись зберігається. Огляд — Огляд.