Сессии и CSRF

Витрина Melbis работает почти в режиме «только чтение» и агрессивно кешируется, поэтому всё, что относится к конкретному посетителю — корзина, авторизация, выбранный язык, — живёт в сессии и куках. Это единственный слой данных, который у каждого посетителя свой.

Этот раздел — про серверную часть: сессию посетителя и защиту форм её токеном. Про куки рассказано в соседнем разделе «Куки», про то, что известно о пришедшем из заголовков запроса, — в разделе «Посетитель».

1. Создание сессии

Сессия создаётся один раз, в корневом скрипте, до вызова Run():

// index.php
require 'units/melbis.php';

MELBIS()->DefineSession('MELBIS_SHOP');

MELBIS()->Run($entry_point, $entry_param);

Полная сигнатура:

DefineSession($mName, $mDomain = '', $mLifeTime = 0, $mPath = '/', $mSecure = true)

Помимо собственно старта сессии метод делает две вещи: генерирует CSRF-токен, если его ещё нет, и включает режим отладки, если в URL пришёл секретный код (см. «Отладчик»).

Если сессия не создана, методы работы с ней вызывать нельзя — обращение к ним без DefineSession приведёт к ошибке.

2. Значения в сессии

// Записать
MELBIS()->SessionSetValue('client_id', $client_id);

// Прочитать
$client_id = MELBIS()->SessionGetValue('client_id');

// Удалить одно значение
MELBIS()->SessionRemoveValue('client_id');

// Уничтожить сессию целиком
MELBIS()->SessionRemove();

SessionGetValue возвращает null, если значения нет, — это удобно отличать от пустой строки, но требует аккуратности при проверках:

$value = MELBIS()->SessionGetValue('basket_id');
$exists = !is_null($value);

SessionRemove очищает данные, уничтожает сессию и гасит cookie — это выход из учётной записи целиком, а не удаление одного ключа.

Идентификатор текущей сессии отдаёт SessionGetId(). Он же доступен в любом шаблоне системным тегом {PHP_SESS_ID}.

3. Приостановка сессии

PHP держит файл сессии заблокированным всё время работы скрипта. Пока запрос не завершится, параллельные запросы того же посетителя — AJAX-вызовы, отложенная загрузка блоков — будут ждать в очереди, даже если сессия им нужна только на чтение.

SessionSuspend() закрывает сессию на запись и снимает блокировку, SessionResume() открывает её снова:

// Данные из сессии уже прочитаны, дальше идёт долгая работа
MELBIS()->SessionSuspend();

$answer = MELBIS_INC_CURL_post($url, $data);   // внешний API, несколько секунд

// Сессия нужна снова — записать результат
MELBIS()->SessionResume();
MELBIS()->SessionSetValue('last_answer', $answer);

Это единственный способ не заставлять параллельные запросы посетителя ждать медленный модуль. Правило простое: между Suspend и Resume сессия недоступна — ни на чтение, ни на запись, — поэтому всё нужное из неё надо прочитать заранее.

4. Защита форм: CSRF-токен

CSRF-атака — это запрос, отправленный на сайт со стороннего ресурса от имени залогиненного посетителя: чужая страница шлёт POST на ваш обработчик, а браузер послушно прикладывает к нему куки сессии. Защита от этого одна — проверять, что форма действительно была отдана вашей витриной. Для этого служит токен, привязанный к сессии посетителя.

Токен создаётся автоматически при DefineSession. В шаблон он попадает системным тегом {CSRF_TOKEN} — передавать его из модуля не нужно, тег доступен в любом шаблоне:

<form method="post" action="/order/">
    <input type="hidden" name="csrf" value="{CSRF_TOKEN}">
    ...
</form>

Модуль-обработчик сверяет пришедшее значение:

$token = $mVars['post']['csrf'] ?? '';
if ( !MELBIS()->SessionCsrfCheck($token) )
{
    // Запрос пришёл не с нашей формы
    return MELBIS()->TplFinal($tpl, 'error');
}

SessionCsrfCheck возвращает true только при точном совпадении с токеном из сессии. Сравнение идёт устойчивым к атаке по времени способом, поэтому подбирать токен посимвольно бесполезно. Если сессия не создана, метод возвращает false — проверка не проходит, а не падает с ошибкой.

Когда форму отправляет не браузер, а скрипт — AJAX, JSON, отложенная загрузка, — тега в разметке может не быть вовсе. Тогда токен берётся из PHP и кладётся в ответ модуля сам:

$token = MELBIS()->SessionCsrfToken();

Проверяется он потом тем же SessionCsrfCheck — принимающей стороне всё равно, из какой формы пришло значение.

Проверку имеет смысл ставить в каждом модуле, который что-то меняет по POST: оформление заказа, отправка формы обратной связи, изменение корзины. На обычных страницах, которые только читают данные, она не нужна.

5. Сессия и кеш

Результат модуля кешируется вместе с его локальными данными. Поэтому значения из сессии нельзя выводить как обычную переменную модуля — первый же посетитель запечёт своё имя в кеш, и его увидят остальные.

Персональные данные передаются только через глобальный массив: он разворачивается на отдельном проходе, который не кешируется никогда. Так шапку сайта можно кешировать целиком, а приветствие всё равно будет персональным.

Готовый пример — в разделе «Глобальный массив» → «Данные посетителя: сессия и кеш».