PHP session handler compatible with Node express-session

Compatible PHP session handler

В современном мире никого не удивить сервис ориентированной архитектурой. Можно сказать что сейчас это уже не менйстрим, а формат разработки. Это не модно, это реально удобно, когда монолит разбит на разные составляющие и каждая задача решается тем инструментом, который лучше для этого подходит. И это справедливо уже не только для энтерпрайза, но и пет проджекты могут состоять из готовых докер контейнеров, реализующих небольшие сервисы.

Например, Node.js отлично справляется с I/O и отлично решает паттерн BFF (Backend for Frontend). Об этом подробнее говорили в недавнем выпуске RadioJS:

Но есть вещи, которые лучше (удобнее/быстрее) писать на других языках. Сейчас я переписываю наш проект GeekJOB.ru и у меня в одной из частей проекта есть воркфлоу, где фронтенд отдается через Node.js, при этом нода хранит сесси в Redis (мне кажется один из приемлемых способов для распределенного хранения сессий). Есть части сервиса, реализованные на PHP (в основном это различный процессинг загружаемых файлов, конвертации, сборка-разборка, вычленение текстов, передача данных на NLP обработку в NER модуль на Python, извлечение смыслов, обработка, etc…).

И вот встал вопрос шеринга сессий. Сессии в PHP и в Node.js формируются по разному и это вообще интересная тема. Сначала я поискал готовые модули, но я не нашел ничего адекватного(по моим меркам) что я мог бы взять в продакшен. Скажем так, во всех них был фатальный недостаток.

Одно из решений, что предлагают разные разработчики: просто брать на стороне PHP айдишник Node.js сессии и модулем Redis ходить в базу и получать данные. Это даже не про работу сессий, это чисто про прочитать данные по ID из базы. Изи? Вери изи. Но, это не механизм сессий, работает только в одностороннем порядке (имеется в виду не запись в базу, а генерация сессии на стороне PHP с последующим принятием ее в Node.js).

Смотрел в сторону готовых решений, но как выяснилось, они требуют допиливания. Кто как смог решить свою задачу — так и сделал. Некоторые библиотеки отпугнули своими реализациями.

Я же захотел разобраться как устроены эти сессии и привести их к единому формату, в итоге у меня получился PHP Session Handler, который регистрируется в PHP, после чего он умеет работать с Node.js express-session сессиями без каких-то костылей. Все очень прозрачно.

Причем PHP умеет не просто работать с готовой сессией от Node.js, но и генерить и выставлять правильную сессию, которую примет Node.js. И это очень важный шаг, потому, что если сессия будет неправильной, вы можете уронить Node.js приложение, так как express-session считает что данные нельзя испортить.

О чем я говорю, давайте поясню. Express кладет в базу сессию с блоком cookie:

Key: session:4-70cG5MqBHH49KRKu6Ae_OqcK9LtKMd
TTL: 1552991427
Type: String
{
"cookie": {
"originalMaxAge": 1553369596120,
"expires": "2068-05-14T08:56:47.279Z",
"httpOnly": true,
"domain": ".geekjob.ru",
"path": "/"
},
{ ... session data objects },
}

Если этого блока или какого-то поля в объекте не будет, то все (матьевосукабля) приложение на Node.js упадет! И пока будет такая битая сессия в базе и на клиенте, приложение будет рестартовать в бесконечном цикле. Таким образом можно вывести из строя целую ноду (мы же деплоим на продакшен не 1 экземпляр сервиса).

А в логах вы увидите:

node_modules/express-session/session/store.js:87
var expires = sess.cookie.expires
^
TypeError: Cannot read property 'expires' of undefined at RedisStore.Store.createSession (node_modules/express-session/session/store.js:87:29) at node_modules/express-session/index.js:478:15

Это жесть. Для своего приложения я сделал фикс и заодно запулил PR:

https://github.com/expressjs/session/pull/634

Но не факт что примут. Поэтому в экосистеме Node.js крайне рекомендуется держать свой приватный NPM со своими форками, но это уже другая история.

Express-session

И так, что из себя представляет сессия из модуля express-session. Выглядит Session ID так:

s%3AFPzoe-5J7jiYa_KjcqOIujWKSE1aGyit.4YtSPd765T1Z7zATngt6D84%2BsjUOKEsM5BIim51f2k4

Что это?

Сессия состоит из префикса, идентификатора и подписи.

s:session_id.signature

Префикс простой “s:” тут ни отнять, ни добавить. Есть и есть, всегда один и тот же.

Подпись

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

const app = express()

app.use(session({
secret: 'some secret salt',
}))

В модуле express-session можно найти формирование куки:

function setcookie(res, name, val, secret, options) {
var signed = 's:' + signature.sign(val, secret);

В свою очередь модуль использует модуль cookie-signature:

var signature = require('cookie-signature')

В котором находим код подписи:

exports.sign = function(val, secret){
return val + '.' +
crypto
.createHmac('sha256',secret)
.update(val)
.digest('base64')
.replace(/=+$/, '')
};

Отлично. Значит, чтобы разобрать эту куку в PHP, нам достаточно выцепить Session ID, и даже можем не реализовывать проверку подписи. Но если хочется прям все по красоте, то реализуем и проверку на подделку.

PHP Session Handler

Раньше для регистрации обработчиков сессий приходилось писать отдельные функции. Но сейчас есть интерфейс и даже класс, от которого можно унаследоваться:

class ExpressjsSessionHandler extends SessionHandler {

В нем мы реализуем только необходимые методы, все остальное будет работать по дефолту.

PHP генерация Session ID like as Node.js Express

 public function create_sid(): string {
$sid = parent::create_sid();
$hmac =
str_replace('=', '',
base64_encode(
hash_hmac('sha256', $sid, $this->secret, true)
)
)
;
return "s:$sid.$hmac";
}

По сути это все тот же код генерации сессии как на Node.js, только на PHP, да. Полный код можно найти в репозитории:

https://github.com/expressjs/session/pull/634

Для работы с Redis использую модуль из PECL, который из коробки умеет работать с сессиями. Ставится очень просто:

pecl install redis

В Docker на базе официальной репы php:fpm ставится так же просто:

FROM php:fpm
...
RUN pecl install redis && docker-php-ext-enable redis

Как пользоваться

Настраиваем Node.js приложение:

app.use(session({
name: 'sid',
secret: 'secret key',
cookie: {
// Share cookie through sub domains
// if you use many domains for service architecture
domain : '.your.domain',
maxAge : Date.now() + 60000
},
store: new RedisStore({
host : 'redis',
port : 6379,
client: redis,
prefix: 'session:',
})
}));

Настраиваем PHP сервис

Ставим модуль через composer:

composer require geekjob/expressjs-session-handler

Настраиваем модуль в рантайме:

require_once 'vendor/autoload.php';
GeekJOBExpressjsSessionHandler::register([
'name' => 'sid',
'secret' => 'secret key',
'cookie' => [
// Share cookie through sub domains
'domain' => '.your.domain',
'path' => '/',
'maxage' => strtotime('+1hour'), // Set maxage
],
'store' => [
'handler' => 'redis',
'path' => 'tcp://127.0.0.1:6379',
'prefix' => 'session:',
],
// Set to true if signature verification is needed.
'secure' => false
]);

У меня управляемая проверка подписи. Если хотите верифицировать подпись — то выставьте флаг secure=true. Тогда каждая сессионная кука будет проверяться на валидность и регенерироваться, если будет подделана.

Для продакшена рекомендую сконфигурировать часть через php.ini (с учетом что это сервис и он поставляется в Docker, то это логично). В частности мы конфигурируем Redis и немного сессионных параметров:

session.session_name = sid
session.save_handler = redis
session.save_path = "tcp://127.0.0.1/?prefix=session:"
session.serialize_handler = php_serialize
; After this number of seconds, stored data will be seen as 'garbage' and
; cleaned up by the garbage collection process.
; http://php.net/session.gc-maxlifetime
; default: session.gc_maxlifetime = 1440
; Redis Sessions use this value for setting TTL
session.gc_maxlifetime = maxage - time()
; Lifetime in seconds of cookie or, if 0, until browser is restarted.
; http://php.net/session.cookie-lifetime
session.cookie_lifetime = maxage - time()

Тогда в самом PHP достаточно будет написать:

require_once 'vendor/autoload.php';
GeekJOBExpressjsSessionHandler::register([
'secret' => 'secret key',
'cookie' => [
'domain' => '.your.domain',
'path' => '/',
],
'secure' => false
]);

Ну а с учетом того, что это микросервис, можно эту логику и вовсе вынести в auto_prepend файл.

PS

Если где-то есть ошибки — пришлите PR, пожалуйста.

Ссылка на packagist.org:

https://github.com/expressjs/session/pull/634