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

Перевірка в браузері

  1. Відкрийте http://localhost:8089/contact.
  2. Надішліть форму без змін (або видаліть hidden _csrf_token у DevTools) — після redirect має з’явитись flash CSRF token mismatch.
  3. Оновіть сторінку, заповніть форму коректно і надішліть знову — 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 нарешті кудись зберігається. Огляд — Огляд.