Konfigurowanie identyfikatora sesji w PHP

Rok temu pisałem o łączeniu staromodnej aplikacji z Laravelem. Po zmianach w konfiguracji środowiska PHP wypłynął nowy problem, a mianowicie Laravelowi nie podobały się identyfikatory sesji generowane przez starą aplikację, więc w ich miejsce generował nowe tym samym skutecznie niwecząc dzielenie sesji.

Identyfikatory generowane przez starą aplikację wyglądały np. tak: mh3e2fj757ed8nnlukqf5ag0f3. Tymczasem Laravel (konkretnie wersja 5.1) za jedynie słuszne uznaje identyfikatory składające się z 40 znaków (cyfry i litery od a do f). Takie zachowanie zdefiniowane jest w metodzie \Illuminate\Session\Store::isValid():

public function isValidId($id)
{
   return is_string($id) && preg_match('/^[a-f0-9]{40}$/', $id);
}

Rozwiązaniem tego problemu jest takie skonfigurowanie sesji PHP-owych, by identyfikatory spełniały wymagania Laravela 5.1:

ini_set('session.hash_function', 'sha1');
ini_set('session.hash_bits_per_character', '4');

Łączenie starej aplikacji z Laravelem

Najciewkawszą rzeczą, którą programowałem ostatnimi czasy, było połączenie kodu dużej i nieco już wiekowej aplikacji z Laravelem, w oparciu o którego planowany jest jej dalszy rozwój.

Do tego problemu można podejść na wiele sposobów, nie ma uniwersalnego rozwiązania, wiele zależy od tego jak wyglądają aplikacje, które chcemy pożenić. W moim wypadku stara aplikacja jest bardzo różnorodna. Jej najstarsza część nawet nie jest obiektowa, nie ma żadnych warstw, to staroszkolne PHP przeplatane HTML-em, z całym gąszczem zmiennych globalnych i konfiguracją opartą na stałych. Nowsza część została zamknięta w miarę sesnownie zaprojektowanych obiektach na modłę Domain Driven Design. Na szczęście już wcześniej stara aplikacja została zrefaktoryzowana o tyle, że punktem wejścia jest jeden kontroler frontowy.

W moich rozważaniach dość szybko odrzuciłem postawienie aplikacji obok siebie (serwery na osobnych IP albo portach) i wykonywanie zapytań z nowej aplikacji do starej. W tym wariancie kod obu aplikacji jest rozdzielony, a ja chciałem skorzystać w nowej aplikacji z sensownej części starej aplikacji (DDD).

Korzystając z faktu, że stara aplikacja ma coś na kształt kontrolera frontowego postanowiłem wrzucić tam kod kontrolera frontowego z Laravela. Ponieważ za 95% akcji odpowiada stara aplikacja, logicznym byłoby umieszczenie laravelowego kodu na końcu, tak by nowa aplikacja była uruchamiana tylko kiedy stara aplikacja nie potrafi obsłużyć zapytania. Niestety, stary kod ustawia nagłówki HTTP, ustawia buforowanie wyjścia danych, robi obsługę błędów przez die() i robi kilka innych rzeczy, które brużdżą nowoczesnym aplikacjom, więc musiałem odwrócić schemat: najpierw uruchamiana jest nowa aplikacja, a jeśli ona nie potrafi obsłużyć zapytania to przekazuje kontrolę do starej aplikacji.

Najważniejszą zmianę wykonałem w app/Http/Kernel.php. Zmieniłem sposób obsługi wyjątku NotFoundHttpException. Normalnie ten wyjątek, jak każdy inny jest obsługiwany wewnątrz aplikacji przez wyświetlenie komunikatu błędu. Po zmianie wyjątek wyrzucany jest poza metodę handle().

public function handle($request)
{
	try {
		$request->enableHttpMethodParameterOverride();
		$response = $this->sendRequestThroughRouter($request);
	} catch (NotFoundHttpException $e) {
		throw $e;
	} catch (\Exception $e) {
		$this->reportException($e);
		$response = $this->renderException($request, $e);
	} catch (\Throwable $e) {
		$e = new \Symfony\Component\Debug\Exception\FatalThrowableError($e);
		$this->reportException($e);
		$response = $this->renderException($request, $e);
	}
	$this->app['events']->fire('kernel.handled', [$request, $response]);
	return $response;
}

Ze wspomnianego wyjątku możemy zrobić użytek w kontrolerze frontowym czyli w pliku public/index.php.

$errorReporting = ini_get('error_reporting');

require __DIR__.'/../../bootstrap/autoload.php';
$app = require_once __DIR__.'/../../bootstrap/app.php';
try {
	/** @var $kernel App\Http\Kernel */
	$kernel   = $app->make(Illuminate\Contracts\Http\Kernel::class);
	$response = $kernel->handle(
		$request = Illuminate\Http\Request::capture()
	);
	$response->send();
	$kernel->terminate($request, $response);
	exit;
} catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) {
	unset($e);
}

ini_set('error_reporting', $errorReporting);
unset($errorReporting, $request, $response, $kernel, $app);

// poniżej znajduje się kod starej aplikacji

Najważniejszą zmianą jest opakowanie standardowego kontrolera frontowego w blok try catch. Jeśli laravelowy kernel wyrzuci NotFoundHttpException to znaczy, że nowa aplikacja nie obsługuje danej akcji i kontrolę należy przekazać do starej aplikacji. Dlatego w catchu nie ma żadnej obsługi błędów, a jedynie usunięcie wyjątku.

W standardowym kontrolerze Laravela ostatnią linią kodu jest wywołanie metody terminate(). Ponieważ w mojej wersji kontrolera dalej jest kod starej aplikacji dodałem funkcję exit(), dzięki czemu wszystko działa jak trzeba.

Kolejną zmianą jest zapamiętanie ustawienia error_reporting w zmiennej przed odpaleniem Laravela. Jest to niezbędne ponieważ w starym kodzie jest sporo kwiatków, które przy najbardziej restrykcyjnych ustawieniach raportowania błędów co i rusz wywalają aplikację. Laravel sam z siebie ustawia właśnie najbardziej restrykcyjne raportowanie błędów. Dlatego jeśli nowa aplikacja nie obsługuje danej akcji przed przejściem do starej aplikacji przywracam bezpieczną wartość error_reporting.

Ostatnią zmianą jest usunięcie wszystkich zmiennych zadeklarowanych przez kod Laravela. To raczej dmuchanie na zimne, bo i bez tego wszystko działało, ale wolę by dla starej aplikacji obecność Laravela była niezauważalna.

Po tych zmianach podstawowe zadanie jest już zrealizowane: akcje obsługiwane przez nową aplikację są obsługiwane przez Laravela, a wszystko co nie jest przez nią obsługiwane trafia do starej aplikacji. Jednak do pełni szczęścia jeszcze trochę brakuje.

Jednym z problemów jest dzielenie sesji pomiędzy starą i nową aplikację. W starej aplikacji sesję przechowuje memcached, co jest konfigurowane przez w php.ini. Laravel ma własne ustawienia sesji konfigurowane w pliku .env oraz config/session.php. W tym pierwszym zmieniłem sterownik sesji na memcached:

SESSION_DRIVER=memcached

W drugim pliku zmiany były następujące:

'cookie' => 'PHPSESSID',
'domain' => '.' . env(APP_DOMAIN),

Celem tych zmian jest dostosowanie parametrów sesji w Laravelu do ustawień starej aplikacji. Ku memu zaskoczeniu trzeba też było wykonać zmianę w config/cache.php.

'prefix' => '',

Okazuje się, że w wypadku trzymania sesji w memcached do klucza dodawany jest właśnie ten prefiks. Bez tej zmiany dla tego samego ID sesji (z ciasteczka) nowa i stara aplikacja budowały inne klucze dla memcached i przez to nie widziały tych samych danych.

Kolejny problem dotyczył wbudowanego w Laravela mechanizmu przeciwdziałania atakom typu Cross Site Request Forgery. Wymaga on tokena przechowywanego w sesji, którego stara aplikacja nie generuje. W rezultacie po przejściu ze strony w starej aplikacji na stronę z nowej aplikacji rzucany był wyjątek VerifyCsrfToken. Nie jestem dumny z tego rozwiązania, ale zdecydowałem się odłożyć rozwiązanie tego problemu na później przez wyłączenie tego mechanizmu po stronie Laravela. W app/Http/Kernel.php wykomentowałem stosowny middleware.

protected $middleware = [
	// ...
	// \Miinto\Http\Middleware\VerifyCsrfToken::class
];

Oczywiście to nie koniec wyzwań związanych z łączeniem obu aplikacji. Skoro już mamy Laravela to można korzystać z jego zalet, m.in. service containera, żeby nie musieć budować wszystkich zależności ręcznie. To z kolei rodzi konieczność refaktoryzowania starego kodu i rugowania zmiennych globalnych, stałych i innych naleciałości, ale to już temat na osobną notkę.